chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Generated at build time by scripts/bundleSdkDocs.ts (snapshot of curated docs/ the
# bundled skills cite). Shipped via files[] but never committed. skills/ IS committed.
/docs
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# Trigger.dev SDK
`@trigger.dev/sdk` - the main customer-facing SDK for writing background tasks.
## Import Rules
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias).
## Key Exports
- `task` - Define a background task
- `schedules.task` - Define a scheduled (cron) task
- `batch` - Batch trigger operations
- `runs` - Run management and polling
- `wait` - Wait for events, delays, or other tasks
- `retry` - Retry utilities
- `queue` - Queue configuration
- `metadata` - Run metadata access
- `logger` - Structured logging
## When Adding Features
1. Implement the feature in the SDK
2. Test with the `hello-world` project in the [`triggerdotdev/references`](https://github.com/triggerdotdev/references) repo
3. Docs updates (`docs/`) are usually done in a separate PR
Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Trigger.dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
<source media="(prefers-color-scheme: light)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/3f5ad4c1-c4c8-4277-b622-290e7f37bd00/public">
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
</picture>
[![npm version](https://img.shields.io/npm/v/@trigger.dev/sdk.svg)](https://www.npmjs.com/package/@trigger.dev/sdk)
[![npm downloads](https://img.shields.io/npm/dm/@trigger.dev/sdk.svg)](https://www.npmjs.com/package/@trigger.dev/sdk)
[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red)](https://github.com/triggerdotdev/trigger.dev)
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs) | [Examples](https://trigger.dev/docs/examples)
</div>
# Official TypeScript SDK for Trigger.dev
The Trigger.dev SDK is a TypeScript/JavaScript library that allows you to define and trigger tasks in your projects.
## About Trigger.dev
Trigger.dev is an open source platform for building and deploying fully-managed AI agents and workflows. Write workflows in normal async TypeScript for everything from simple tasks to long-running AI agents, heavy media processing, complex real-time systems and more. Complete with full observability, managed queues, and elastic infrastructure which handles the horizontal scaling.
## Core features
- Task creation and execution
- CLI for development and deployment
- Build system with extensions
- Management API for runs, schedules, and environment variables
## Key Components:
- Tasks: Background jobs written in TypeScript/JavaScript
- CLI: Commands for login, init, dev, deploy
- Build Extensions: Customize builds (Prisma, Python, FFmpeg, etc.)
- Management API: Programmatic control over runs and resources
## Getting started
There are two ways to get started:
1. Create an account in our [web app](https://cloud.trigger.dev), create a new project and follow the instructions in the onboarding. Build and deploy your first task in minutes.
2. [Manual setup](https://trigger.dev/docs/manual-setup) in your existing project.
## SDK documentation
For more information on our SDK, refer to our [docs](https://trigger.dev/docs/introduction).
## Support
If you have any questions, please reach out to us on [Discord](https://trigger.dev/discord) and we'll be happy to help.
+220
View File
@@ -0,0 +1,220 @@
{
"name": "@trigger.dev/sdk",
"version": "4.5.3",
"description": "trigger.dev Node.JS SDK",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/trigger-sdk"
},
"type": "module",
"sideEffects": false,
"files": [
"dist",
"docs",
"skills"
],
"tshy": {
"selfLink": false,
"main": true,
"module": true,
"project": "./tsconfig.build.json",
"exports": {
"./package.json": "./package.json",
".": "./src/v3/index.ts",
"./v3": "./src/v3/index.ts",
"./ai": "./src/v3/ai.ts",
"./ai/skills-runtime": "./src/v3/agentSkillsRuntime.ts",
"./ai/test": "./src/v3/test/index.ts",
"./chat": "./src/v3/chat.ts",
"./chat/react": "./src/v3/chat-react.ts",
"./chat-server": "./src/v3/chat-server.ts"
},
"sourceDialects": [
"@triggerdotdev/source"
]
},
"typesVersions": {
"*": {
"v3": [
"dist/commonjs/v3/index.d.ts"
],
"ai": [
"dist/commonjs/v3/ai.d.ts"
],
"ai/skills-runtime": [
"dist/commonjs/v3/agentSkillsRuntime.d.ts"
],
"ai/test": [
"dist/commonjs/v3/test/index.d.ts"
],
"chat": [
"dist/commonjs/v3/chat.d.ts"
],
"chat/react": [
"dist/commonjs/v3/chat-react.d.ts"
],
"chat-server": [
"dist/commonjs/v3/chat-server.d.ts"
]
}
},
"scripts": {
"clean": "rimraf dist docs .tshy .tshy-build .turbo",
"build": "tshy && pnpm run update-version && pnpm run bundle-docs",
"bundle-docs": "tsx ../../scripts/bundleSdkDocs.ts",
"dev": "tshy --watch",
"typecheck": "tsc --noEmit",
"typecheck:ai-v7": "tsc --noEmit -p tsconfig.ai-v7.json",
"test": "vitest",
"update-version": "tsx ../../scripts/updateVersion.ts",
"check-exports": "attw --pack ."
},
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/semantic-conventions": "1.41.1",
"@trigger.dev/core": "workspace:4.5.3",
"chalk": "^5.2.0",
"cronstrue": "^2.21.0",
"debug": "^4.3.4",
"evt": "^2.4.13",
"slug": "^6.0.0",
"ulid": "^2.3.0",
"uncrypto": "^0.1.3",
"ws": "^8.11.0"
},
"devDependencies": {
"@ai-sdk/provider": "3.0.8",
"@arethetypeswrong/cli": "^0.15.4",
"@types/debug": "^4.1.7",
"@types/react": "^19.2.14",
"@types/slug": "^5.0.3",
"@types/ws": "^8.5.3",
"ai": "^6.0.116",
"ai-v7": "npm:ai@7.0.0-canary.159",
"encoding": "^0.1.13",
"rimraf": "^6.0.1",
"tshy": "^3.0.2",
"tsx": "4.17.0",
"typed-emitter": "^2.1.0",
"zod": "3.25.76"
},
"peerDependencies": {
"@ai-sdk/otel": ">=1.0.0-0 <2",
"ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8",
"react": "^18.0 || ^19.0",
"zod": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/otel": {
"optional": true
},
"ai": {
"optional": true
},
"react": {
"optional": true
}
},
"engines": {
"node": ">=18.20.0"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"@triggerdotdev/source": "./src/v3/index.ts",
"types": "./dist/esm/v3/index.d.ts",
"default": "./dist/esm/v3/index.js"
},
"require": {
"types": "./dist/commonjs/v3/index.d.ts",
"default": "./dist/commonjs/v3/index.js"
}
},
"./v3": {
"import": {
"@triggerdotdev/source": "./src/v3/index.ts",
"types": "./dist/esm/v3/index.d.ts",
"default": "./dist/esm/v3/index.js"
},
"require": {
"types": "./dist/commonjs/v3/index.d.ts",
"default": "./dist/commonjs/v3/index.js"
}
},
"./ai": {
"import": {
"@triggerdotdev/source": "./src/v3/ai.ts",
"types": "./dist/esm/v3/ai.d.ts",
"default": "./dist/esm/v3/ai.js"
},
"require": {
"types": "./dist/commonjs/v3/ai.d.ts",
"default": "./dist/commonjs/v3/ai.js"
}
},
"./ai/skills-runtime": {
"import": {
"@triggerdotdev/source": "./src/v3/agentSkillsRuntime.ts",
"types": "./dist/esm/v3/agentSkillsRuntime.d.ts",
"default": "./dist/esm/v3/agentSkillsRuntime.js"
},
"require": {
"types": "./dist/commonjs/v3/agentSkillsRuntime.d.ts",
"default": "./dist/commonjs/v3/agentSkillsRuntime.js"
}
},
"./ai/test": {
"import": {
"@triggerdotdev/source": "./src/v3/test/index.ts",
"types": "./dist/esm/v3/test/index.d.ts",
"default": "./dist/esm/v3/test/index.js"
},
"require": {
"types": "./dist/commonjs/v3/test/index.d.ts",
"default": "./dist/commonjs/v3/test/index.js"
}
},
"./chat": {
"import": {
"@triggerdotdev/source": "./src/v3/chat.ts",
"types": "./dist/esm/v3/chat.d.ts",
"default": "./dist/esm/v3/chat.js"
},
"require": {
"types": "./dist/commonjs/v3/chat.d.ts",
"default": "./dist/commonjs/v3/chat.js"
}
},
"./chat/react": {
"import": {
"@triggerdotdev/source": "./src/v3/chat-react.ts",
"types": "./dist/esm/v3/chat-react.d.ts",
"default": "./dist/esm/v3/chat-react.js"
},
"require": {
"types": "./dist/commonjs/v3/chat-react.d.ts",
"default": "./dist/commonjs/v3/chat-react.js"
}
},
"./chat-server": {
"import": {
"@triggerdotdev/source": "./src/v3/chat-server.ts",
"types": "./dist/esm/v3/chat-server.d.ts",
"default": "./dist/esm/v3/chat-server.js"
},
"require": {
"types": "./dist/commonjs/v3/chat-server.d.ts",
"default": "./dist/commonjs/v3/chat-server.js"
}
}
},
"main": "./dist/commonjs/v3/index.js",
"types": "./dist/commonjs/v3/index.d.ts",
"module": "./dist/esm/v3/index.js"
}
@@ -0,0 +1,296 @@
---
name: trigger-authoring-chat-agent
description: >
Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn
run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult
vs calling chat.pipe(), the two server actions (chat.createStartSessionAction +
auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building,
modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React
transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK
streamText route to chat.agent.
type: core
library: trigger.dev
sources:
- docs/ai-chat/overview.mdx
- docs/ai-chat/quick-start.mdx
- docs/ai-chat/how-it-works.mdx
- docs/ai-chat/backend.mdx
- docs/ai-chat/frontend.mdx
- docs/ai-chat/reference.mdx
- docs/ai-chat/types.mdx
- docs/ai-chat/tools.mdx
- docs/ai-chat/lifecycle-hooks.mdx
- docs/ai-chat/error-handling.mdx
---
# Authoring a chat agent
A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a
message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle
gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out.
There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so
history accumulates server-side and the client ships only the new message each turn.
Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced
(the SDK registers it for you).
## Setup
Three pieces: the agent task, two server actions, and the frontend transport.
### 1. Define the agent
```ts trigger/chat.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.agent({
id: "my-chat",
run: async ({ messages, signal }) =>
streamText({
// Spread this FIRST. See "Common mistakes".
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's
`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the
`StreamTextResult` auto-pipes it to the frontend.
### 2. Add two server actions
Both run on your server, so the browser never holds your environment secret key. This is also
where per-user / per-plan authorization and any paired DB writes live.
```ts app/actions.ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId).
export const startChatSession = chat.createStartSessionAction("my-chat");
// Pure mint. The transport calls this on 401/403 to refresh an expired token.
export async function mintChatAccessToken(chatId: string) {
return auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
expirationTime: "1h",
});
}
```
### 3. Wire the frontend
```tsx app/components/chat.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat", // typeof myChat gives compile-time task-id validation
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
});
const { messages, sendMessage, stop, status } = useChat({ transport });
const [input, setInput] = useState("");
// render messages, a form that calls sendMessage({ text: input }),
// and a Stop button (onClick={stop}) while status === "streaming".
}
```
The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the
agent's message type through `useChat`.
## Core patterns
### 1. Return vs pipe
Return the `streamText` result from `run` for the simple case. When `streamText` is called deep
inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let
`run` resolve `void`.
```ts
export const agentChat = chat.agent({
id: "agent-chat",
run: async ({ messages }) => {
await runAgentLoop(messages); // don't return; pipe inside
},
});
async function runAgentLoop(messages: ModelMessage[]) {
const result = streamText({
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
});
await chat.pipe(result); // works from anywhere in the task
}
```
### 2. Typed tools (declare on config AND spread back)
Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass
that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere.
```ts
import { tool, stepCountIs } from "ai";
import { z } from "zod";
const tools = {
searchDocs: tool({
description: "Search the docs.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchIndex(query),
}),
};
export const myChat = chat.agent({
id: "my-chat",
tools, // so toModelOutput survives across turns
run: async ({ messages, tools, signal }) =>
streamText({
...chat.toStreamTextOptions({ tools }), // same set, handed back typed
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries
`chatId`, `turn`, `continuation`, and `clientData`.
### 3. Custom data parts (persisted vs transient)
`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks)
persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to
stream them without persisting. Writes via `chat.stream` are always ephemeral.
```ts
// In run() - persists, surfaces in onTurnComplete's responseMessage
chat.response.write({ type: "data-context", data: { searchResults } });
// In a hook via writer - streams but does NOT persist
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });
```
### 4. Custom UIMessage type, client data, and builder hooks
For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage<T>()` and
`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the
matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged,
agent wins).
```ts
export const myChat = chat
.withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } })
.withClientData({ schema: z.object({ userId: z.string() }) })
.agent({
id: "my-chat",
tools: myTools,
onTurnStart: async ({ uiMessages, writer }) => {
writer.write({ type: "data-turn-status", data: { status: "preparing" } });
},
run: async ({ messages, tools, signal }) =>
streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }),
});
```
Build `MyChatUIMessage` as `UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>` (or, for
tools only, `InferChatUIMessageFromTools<typeof tools>` from `@trigger.dev/sdk/ai`). On the
frontend, narrow `useChat` with `InferChatUIMessage<typeof myChat>` from `@trigger.dev/sdk/chat/react`.
### 5. Lifecycle hooks and stop
`chat.agent` accepts hooks that fire in a fixed per-turn order:
```text
onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only)
-> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
```
`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where
`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's
first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include
`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30),
`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent`
runs with `maxAttempts: 1` internally.
Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as
`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating
server-side.
```ts
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
```
### 6. Migrating from a plain AI SDK `streamText` route
There is no API route in this model. The transport replaces the route round-trip, so:
- Delete the route handler. Move per-request auth into the two server actions from Setup step 2.
- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`.
- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first.
- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape.
## Common mistakes
- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.**
```ts
// Wrong - compaction / steering / background injection silently no-op
return streamText({ model, messages, abortSignal: signal });
// Correct - spread FIRST so explicit overrides win
return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
```
It wires the `prepareStep` callback behind compaction, mid-turn steering, and background
injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds
telemetry. Omitting it makes all of those silently no-op with no error.
- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them
back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's
`toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns.
- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the
model keeps generating server-side.
- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires
once per chat, so continuation runs skip it and crash with
`chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker.
- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via
the two server actions; the transport calls them.
- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it
only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
`seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty.
- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys,
stack traces). Return a sanitized string instead.
## References
- `trigger-chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives
(`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery.
- `trigger-realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `trigger-authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/`. Start with `quick-start.mdx`, `backend.mdx`, `tools.mdx`, `types.mdx`, `frontend.mdx`.
A `chat.agent` is a Trigger.dev task, so it builds and deploys like any other. For `trigger.config.ts` and build extensions (Prisma, Playwright, Python, FFmpeg, etc. — e.g. when a tool needs them), read the bundled config docs under `@trigger.dev/sdk/docs/config/` (extensions are in `config/extensions/`, starting with `overview.mdx`).
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,254 @@
---
name: trigger-authoring-tasks
description: >
Covers writing backend Trigger.dev tasks with @trigger.dev/sdk: defining task() and
schemaTask(), the run function and its ctx, retries, waits, queues and concurrency,
idempotency keys, run metadata, logging, triggering other tasks (and the Result shape),
scheduled/cron tasks, and the essentials of trigger.config.ts. Load this whenever you are
authoring or editing code inside a /trigger directory, defining a task, or writing backend
code that triggers tasks. Realtime/React hooks and AI chat are covered by separate skills.
type: core
library: trigger.dev
sources:
- docs/tasks/overview.mdx
- docs/tasks/schemaTask.mdx
- docs/tasks/scheduled.mdx
- docs/triggering.mdx
- docs/queue-concurrency.mdx
- docs/idempotency.mdx
- docs/runs/metadata.mdx
- docs/logging.mdx
- docs/errors-retrying.mdx
- docs/wait.mdx
- docs/wait-for.mdx
- docs/wait-until.mdx
- docs/wait-for-token.mdx
- docs/context.mdx
- docs/config/config-file.mdx
---
# Authoring Trigger.dev Tasks
Tasks are functions that can run for a long time with strong resilience to failure. Define them in files under your `/trigger` directory. Always import from `@trigger.dev/sdk`. Never import from `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
## Setup
```ts
// /trigger/hello-world.ts
import { task } from "@trigger.dev/sdk";
export const helloWorld = task({
id: "hello-world", // unique within the project
run: async (payload: { message: string }, { ctx }) => {
console.log(payload.message, "attempt", ctx.attempt.number);
return { ok: true }; // must be JSON serializable
},
});
```
The `run` function receives the payload and a second argument with `ctx` (run context), an abort `signal`, and a deprecated `init` output. The return value is the task output and must be JSON serializable.
## Core patterns
### 1. Validate the payload with `schemaTask`
`schema` accepts a Zod / Yup / Superstruct / ArkType / valibot / typebox parser or a custom `(data: unknown) => T` function. A validation failure throws `TaskPayloadParsedError` and skips retrying.
```ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const createUser = schemaTask({
id: "create-user",
schema: z.object({ name: z.string(), age: z.number() }),
run: async (payload) => ({ greeting: `Hi ${payload.name}` }),
});
```
### 2. Configure retries and abort early
The default `maxAttempts` is 3. Throw `AbortTaskRunError` to stop retrying immediately. Task-level `retry` overrides the config-file defaults.
```ts
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
export const charge = task({
id: "charge",
retry: { maxAttempts: 5, factor: 1.8, minTimeoutInMs: 500, maxTimeoutInMs: 30_000, randomize: true },
run: async (payload: { amount: number }) => {
if (payload.amount <= 0) throw new AbortTaskRunError("Invalid amount"); // no retry
// work that may throw and retry
},
});
```
For finer control, `catchError: async ({ payload, error, ctx, retryAt }) => {...}` can return `{ skipRetrying: true }`, `{ retryAt: Date }`, or `undefined` (use normal logic). `retry.onThrow`, `retry.fetch`, also exist for in-task retrying.
### 3. Trigger another task and handle the Result
From inside a task use `yourTask.triggerAndWait(payload)`. The result is a Result object that you must check (`ok`), or `.unwrap()` to throw on failure.
```ts
export const parentTask = task({
id: "parent-task",
run: async () => {
const result = await childTask.triggerAndWait({ data: "x" });
if (result.ok) return result.output; // typed child output
console.error("child failed", result.error);
// or: const output = await childTask.triggerAndWait({ data: "x" }).unwrap();
},
});
```
`SubtaskUnwrapError` carries `runId`, `taskId`, and `cause`. For fan-out use `childTask.batchTriggerAndWait([{ payload: a }, { payload: b }])`; the result has a `.runs` array, each entry `{ ok, id, output?, error?, taskIdentifier }`.
### 4. Trigger from backend code with a type-only import
Outside a task, import the task type only and trigger by id. Do not import the task instance into backend bundles.
```ts
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";
const handle = await tasks.trigger<typeof emailSequence>(
"email-sequence",
{ to: "a@b.com", name: "Ada" },
{ delay: "1h" }
);
```
`tasks.batchTrigger` and `batch.trigger([{ id, payload }])` cover batches. Trigger options include `delay`, `ttl`, `idempotencyKey`, `idempotencyKeyTTL`, `debounce`, `queue`, `concurrencyKey`, `maxAttempts`, `tags`, `metadata`, `priority`, `region`, and `machine`. Inspect runs with `runs.retrieve`, `runs.cancel`, and `runs.reschedule`.
### 5. Idempotency keys
`idempotencyKeys.create(key, { scope })` returns a 64-char hashed key. A raw string key defaults to `"run"` scope (v4.3.1+); for once-ever behavior use `scope: "global"`.
```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const processOrder = task({
id: "process-order",
run: async (payload: { orderId: string; email: string }) => {
const key = await idempotencyKeys.create(`confirm-${payload.orderId}`);
await sendEmail.trigger({ to: payload.email }, { idempotencyKey: key });
},
});
```
### 6. Waits and run metadata
`wait.for({ seconds })` and `wait.until({ date })` durably pause the run. `metadata.*` is readable and writable only inside `run()`; updates are synchronous and chainable (`set`, `del`, `replace`, `append`, `remove`, `increment`, `decrement`).
```ts
import { task, metadata, wait } from "@trigger.dev/sdk";
export const importer = task({
id: "importer",
run: async (payload: { rows: unknown[] }) => {
metadata.set("status", "processing").set("total", payload.rows.length);
await wait.for({ seconds: 5 });
metadata.set("status", "complete");
},
});
```
For human-in-the-loop, `wait.createToken({ timeout, tags })` returns `{ id, url, publicAccessToken, ... }`; resume with `wait.forToken<T>(token: string | { id: string })` which returns `{ ok, output?, error? }` (or `.unwrap()`), and complete it elsewhere with `wait.completeToken(tokenId, output)`. Metadata max is 256KB and is not propagated to child tasks; push values to a parent with `metadata.parent.*` / `metadata.root.*`. (`metadata.stream` is deprecated since 4.1.0 in favor of `streams.pipe()`.)
### 7. Scheduled (cron) tasks
```ts
import { schedules } from "@trigger.dev/sdk";
export const dailyReport = schedules.task({
id: "daily-report",
cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo" },
run: async (payload) => {
console.log("scheduled at", payload.timestamp, "next", payload.upcoming);
},
});
```
The payload includes `timestamp`, `lastTimestamp`, `timezone`, `scheduleId`, `externalId`, and `upcoming`. Attach schedules dynamically with `schedules.create({ task, cron, timezone?, externalId?, deduplicationKey })` (the dedup key is required and per-project), plus `retrieve / list / update / activate / deactivate / del / timezones`.
### 8. Queues and concurrency
Set `queue: { concurrencyLimit }` on a task, or share a queue across tasks:
```ts
import { queue, task } from "@trigger.dev/sdk";
export const emails = queue({ name: "emails", concurrencyLimit: 5 });
export const sendEmail = task({ id: "send-email", queue: emails, run: async () => {} });
```
At trigger time override with `{ queue: "queue-name" }` and add `concurrencyKey` for per-tenant queues. Manage queues with `queues.list / retrieve / pause / resume / overrideConcurrencyLimit / resetConcurrencyLimit`.
### 9. `trigger.config.ts` essentials
```ts
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "<project ref>",
dirs: ["./trigger"],
machine: "small-1x",
retries: {
enabledInDev: false,
default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true },
},
});
```
`build.external` controls which packages stay out of the bundle. Build extensions (`additionalFiles`, `prismaExtension`, `puppeteer`, `playwright`, `ffmpeg`, `pythonExtension`, `aptGet`, `syncEnvVars`, etc.) come from `@trigger.dev/build`. `telemetry` configures instrumentations and exporters. Each extension has its own setup doc, all bundled under `@trigger.dev/sdk/docs/config/extensions/` (start with `overview.mdx`); read the one you need before wiring it up rather than guessing the API.
### Logging
`logger.debug / log / info / warn / error(message, dataRecord?)` write structured logs; `logger.trace(name, async (span) => {...})` adds a span. Module-level metrics use `otel.metrics.getMeter(name)`.
## Common mistakes
1. **CRITICAL: Treating the wait result as the output.** `triggerAndWait` and `wait.forToken` return a Result object, not the raw output.
- Wrong: `const out = await childTask.triggerAndWait(p); use(out.foo);`
- Correct: `const r = await childTask.triggerAndWait(p); if (r.ok) use(r.output.foo);` (or `.unwrap()`).
2. **Wrapping `triggerAndWait` / `batchTriggerAndWait` / `wait` in `Promise.all`.**
- Wrong: `await Promise.all([childTask.triggerAndWait(a), childTask.triggerAndWait(b)]);`
- Correct: `await childTask.batchTriggerAndWait([{ payload: a }, { payload: b }]);` (or a sequential for-loop).
3. **Importing the task instance into backend code.**
- Wrong: `import { emailSequence } from "~/trigger/emails";` in a route handler.
- Correct: `import type { emailSequence }` plus `tasks.trigger<typeof emailSequence>("email-sequence", payload)`.
4. **Calling `metadata.set/get` outside `run()`.**
- Wrong: setting metadata at module scope or in unrelated backend code (a no-op; `get` returns `undefined`).
- Correct: call inside `run()` or a task lifecycle hook.
5. **Assuming child tasks inherit the parent's queue or metadata.**
- Wrong: expecting a subtask to share the parent's `concurrencyLimit` or see its metadata.
- Correct: subtasks run on their own queue; pass metadata explicitly via `{ metadata: metadata.current() }`, or push up with `metadata.parent.*`.
6. **Bundling native/WASM packages.**
- Wrong: leaving `sharp`, `re2`, `sqlite3`, or WASM packages in the default bundle.
- Correct: add them to `build.external` in `trigger.config.ts`.
7. **Relying on a raw string idempotency key being global.**
- Wrong: `trigger(p, { idempotencyKey: "welcome-email" })` expecting once-ever (true only in v4.3.0 and earlier).
- Correct: `await idempotencyKeys.create("welcome-email", { scope: "global" })`.
## References
Sibling skills:
- **trigger-realtime-and-frontend** for subscribing to runs and triggering from the frontend with React hooks.
- **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** for building AI chat agents.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with:
- `@trigger.dev/sdk/docs/tasks/overview.mdx`
- `@trigger.dev/sdk/docs/triggering.mdx`
- `@trigger.dev/sdk/docs/config/config-file.mdx`
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,368 @@
---
name: trigger-chat-agent-advanced
description: >
Advanced and operational chat.agent capabilities for Trigger.dev, loaded on demand. Load this when
working on the raw Sessions primitive (sessions / SessionHandle), a custom chat transport or the
realtime wire protocol, durable sub-agents (AgentChat, chat.stream.writer), human-in-the-loop,
steering, actions, background injection (chat.defer / chat.inject), fast starts (preload, Head
Start via @trigger.dev/sdk/chat-server), context resilience (compaction, recovery boot, OOM, large
payloads), chat.local run-scoped state, offline testing with mockChatAgent, or prerelease/version
upgrades. For the everyday chat.agent({...}) definition and the useTriggerChatTransport happy path,
use the trigger-authoring-chat-agent skill instead.
type: core
library: trigger.dev
sources:
- docs/ai-chat/sessions.mdx
- docs/ai-chat/server-chat.mdx
- docs/ai-chat/client-protocol.mdx
- docs/ai-chat/pending-messages.mdx
- docs/ai-chat/actions.mdx
- docs/ai-chat/background-injection.mdx
- docs/ai-chat/compaction.mdx
- docs/ai-chat/fast-starts.mdx
- docs/ai-chat/chat-local.mdx
- docs/ai-chat/mcp.mdx
- docs/ai-chat/testing.mdx
- docs/ai-chat/upgrade-guide.mdx
- docs/ai-chat/patterns/sub-agents.mdx
- docs/ai-chat/patterns/human-in-the-loop.mdx
- docs/ai-chat/patterns/persistence-and-replay.mdx
- docs/ai-chat/patterns/recovery-boot.mdx
- docs/ai-chat/patterns/oom-resilience.mdx
- docs/ai-chat/patterns/large-payloads.mdx
- docs/ai-chat/patterns/version-upgrades.mdx
- docs/ai-chat/tools.mdx
---
# chat.agent: advanced and operational
`chat.agent` is built on **Sessions**: a durable, task-bound, bi-directional I/O channel pair keyed
on a stable `externalId` (e.g. `chatId`) that outlives any single run. This skill covers the layers
beneath and around the everyday agent: the raw `sessions` API, server-side `AgentChat`, durable
sub-agents, actions / background injection, fast starts, compaction and recovery, and the wire
protocol for custom transports.
Two `chat` namespaces are easy to confuse: the agent definition imports `chat` from
`@trigger.dev/sdk/ai`; Head Start / Node-listener server entries import `chat` from
`@trigger.dev/sdk/chat-server`.
## Setup
Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`.
```ts
import { AgentChat } from "@trigger.dev/sdk/chat";
import type { myAgent } from "./trigger/my-agent";
const chat = new AgentChat<typeof myAgent>({ agent: "my-chat", clientData: { userId: "user_123" } });
const stream = await chat.sendMessage("Review PR #42");
const text = await stream.text();
await chat.close();
```
`sendMessage()` triggers a run on the first call, then reuses it via input streams. `ChatStream`
exposes `text()`, `result()` (`{ text, toolCalls, toolResults }`), `messages()` (UIMessage
snapshots), and the raw `.stream`. Other methods: `steer(text)`, `stop()`, `sendRaw(uiMessages)`,
`sendAction(action)`, `preload()`, `reconnect()`.
## Core patterns
### 1. Raw Sessions for non-chat, bi-directional I/O
Reach for `sessions` directly when the chat abstraction does not fit: agent inboxes, approval flows,
server-to-server pipelines. `sessions.start` is idempotent on `(env, externalId)`; `externalId`
cannot start with `session_`.
```ts
import { sessions } from "@trigger.dev/sdk";
const { id, publicAccessToken } = await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "my-chat",
triggerConfig: { tags: [`chat:${chatId}`], basePayload: { chatId, trigger: "preload" } },
});
const session = sessions.open(chatId); // no network call; methods are lazy
await session.out.append({ kind: "message", text: "hello" });
const next = await session.in.once<MyEvent>({ timeoutMs: 30_000 });
```
`sessions.open(id).in` also has `send`, `on(handler)`, `peek`, `wait` (suspends the run, only inside
`task.run()`), and `waitWithIdleTimeout`. `.out` has `append`, `pipe`, `writer`, `read`,
`writeControl`, and `trimTo`. List with `sessions.list({ type, tag, status, ... })` (`for await`),
mutate with `sessions.update`, end with `sessions.close` (terminal, idempotent).
### 2. Durable sub-agent as a streaming tool
`AgentChat` inside an AI SDK `tool()` delegates to a durable sub-agent; its response streams as
preliminary tool results. Give the tool a `toModelOutput` so the model sees a compact summary.
```ts
import { tool } from "ai";
import { AgentChat } from "@trigger.dev/sdk/chat";
import { z } from "zod";
const researchTool = tool({
description: "Delegate research to a specialist agent.",
inputSchema: z.object({ topic: z.string() }),
execute: async function* ({ topic }, { abortSignal }) {
const chat = new AgentChat({ agent: "research-agent" });
const stream = await chat.sendMessage(topic, { abortSignal });
yield* stream.messages(); // UIMessage snapshots become preliminary tool results
await chat.close();
},
toModelOutput: ({ output: message }) => {
const lastText = message?.parts?.findLast((p: { type: string }) => p.type === "text") as
| { text?: string }
| undefined;
return { type: "text", value: lastText?.text ?? "Done." };
},
});
```
For a subtask exposed via `execute: ai.toolExecute(task)`, stream progress to the agent's run with
`chat.stream.writer({ target: "root" })`. `target` accepts `"self" | "parent" | "root" | <runId>`.
Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow<typeof myChat>()`
(`{ chatId, turn, continuation, clientData }`).
```ts
import { chat, ai } from "@trigger.dev/sdk/ai";
const { waitUntilComplete } = chat.stream.writer({
target: "root",
execute: ({ write }) =>
write({ type: "data-research-status", id: partId, data: { query, status: "in-progress" } }),
});
await waitUntilComplete();
```
### 3. Background injection: defer + inject
`chat.defer(promise)` runs work in parallel with streaming (all deferred promises are awaited, with a
5s timeout, before `onTurnComplete`). `chat.inject(messages)` queues `ModelMessage[]` that drain at
the next turn start or `prepareStep` boundary.
```ts
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
chat.defer(
(async () => {
const analysis = await analyzeConversation(messages);
chat.inject([{ role: "system", content: `[Analysis]\n\n${analysis}` }]);
})()
);
},
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal, stopWhen: stepCountIs(15) }),
});
```
### 4. Compaction (threshold-based)
`compaction.shouldCompact` decides when, `summarize` produces the summary that replaces the model
messages. UI messages are preserved by default (customize via `compactUIMessages`). The `prepareStep`
that performs inner-loop compaction is auto-injected by `chat.toStreamTextOptions()`; a `prepareStep`
you pass after the spread wins.
```ts
compaction: {
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
summarize: async ({ messages }) =>
(await generateText({
model: anthropic("claude-haiku-4-5"),
messages: [...messages, { role: "user", content: "Summarize concisely." }],
})).text,
},
```
### 5. Actions: mutate state without a turn
`actionSchema` validates; `onAction` mutates via `chat.history` (`slice`, `replace`, `rollbackTo`,
`remove`, `getPendingToolCalls`, `extractNewToolResults`). Actions fire `hydrateMessages` and
`onAction` only, never `run()` or the turn hooks. Return a `StreamTextResult`, string, or `UIMessage`
to also emit a model response.
```ts
export const myChat = chat.agent({
id: "my-chat",
actionSchema: z.discriminatedUnion("type", [
z.object({ type: z.literal("undo") }),
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
]),
onAction: async ({ action }) => {
if (action.type === "undo") chat.history.slice(0, -2);
if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId);
},
run: async ({ messages, signal }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
});
```
Send from the browser with `transport.sendAction(chatId, { type: "undo" })`, or server-side with
`agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" })`.
### 6. Fast starts: Head Start
`chat.headStart` (from `@trigger.dev/sdk/chat-server`, NOT `/ai`) returns a Web Fetch handler that
serves turn 1 from your own warm process, then hands off to the agent on turn 2+. Tools passed here
must be **schema-only** (a module importing `ai` + `zod` only); heavy executes stay in the task.
```ts
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { headStartTools } from "@/lib/chat-tools/schemas";
export const chatHandler = chat.headStart({
agentId: "my-chat",
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools: headStartTools }),
model: anthropic("claude-sonnet-4-6"),
system: "You are helpful.",
stopWhen: stepCountIs(15),
}),
});
// Next.js: export const POST = chatHandler; Transport: headStart: "/api/chat"
```
Node-only frameworks wrap a Web Fetch handler with `chat.toNodeListener(handler)`. Use the **same
model** on both sides to avoid a tone shift between turn 1 and turn 2+.
### 7. chat.local: init in onBoot, not onChatStart
`chat.local<T>({ id })` is module-level, shallow-proxy, run-scoped state. Initialize it in `onBoot`
(fires on every fresh worker, including continuation runs), never `onChatStart`.
```ts
const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" });
export const myChat = chat.agent({
id: "my-chat",
onBoot: async ({ clientData }) => userContext.init({ name: "Alice", plan: "pro" }),
run: async ({ messages, signal }) => streamText({ /* ... */ }),
});
```
### 8. Pending messages (mid-stream user input)
A message sent while a turn is streaming should NOT cancel the stream. Configure
`pendingMessages` (`shouldInject`, `prepare`, `onReceived`, `onInjected`) on the agent so the SDK's
auto-injected `prepareStep` folds them in at the next boundary. On the frontend, `usePendingMessages`
returns `pending`, `steer(text)`, `queue(text)`, and `promoteToSteering(id)`; send via
`transport.sendPendingMessage(chatId, uiMessage, metadata?)`.
### 9. Recovery and version upgrades
`onRecoveryBoot` fires only when a **partial assistant message exists on the tail** (interrupted
deploy, crash, OOM retry). It does NOT fire on `chat.requestUpgrade()`, which is a graceful exit with
no partial. `chat.requestUpgrade()` (called in `onTurnStart` / `onValidateMessages` to skip `run()`,
or in `run()` / `chat.defer()` to exit after the turn) rotates the Session's `currentRunId` to a run
on the latest deployment without a client reconnect. Pair it with a contract version on `clientData`.
```ts
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);
onTurnStart: async ({ clientData }) => {
if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
chat.requestUpgrade();
}
},
```
For OOM resilience, set `oomMachine` (and `machine`) on the agent so retries land on a larger preset.
### 10. Offline testing with mockChatAgent
`@trigger.dev/sdk/ai/test` runs the real turn loop in-memory. Import it **before** the agent module
so the resource catalog is installed. Drive with `sendMessage`, `sendRegenerate`, `sendAction`,
`sendStop`, `sendHeadStart`, `sendHandover`; seed state with `seedSnapshot` / `seedSessionOutTail` /
`seedSessionOutPartial` / `seedSessionInTail`; assert against `turn.chunks` and `harness.allChunks`.
```ts
import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; // BEFORE the agent module
import { myChatAgent } from "./my-chat.js";
const harness = mockChatAgent(myChatAgent, { chatId: "test-1", clientData: { model } });
try {
const turn = await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] });
// assert against turn.chunks
} finally {
await harness.close();
}
```
Options include `mode` (`"preload" | "submit-message" | "handover-prepare" | "continuation"`),
`preload`, `continuation`, `previousRunId`, `snapshot`, `taskContext`, and `setupLocals`. Set
`taskContext.ctx.attempt.number > 1` to simulate an OOM-retry attempt. `runInMockTaskContext` drives a
non-chat task offline.
### 11. Custom transport: the wire protocol
Endpoints: `POST /api/v1/sessions` (create), `GET /realtime/v1/sessions/{id}/out` (SSE),
`POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/sessions/{id}/close`. `ChatInputChunk` is
`{ kind: "message"; payload: ChatTaskWirePayload } | { kind: "stop"; message? }`. The
`ChatTaskWirePayload` carries `chatId`, `trigger` (`submit-message | regenerate-message | preload |
close | action | handover-prepare`), `message?`, `metadata?`, `action?`, `continuation?`,
`previousRunId?`, and more. Control records are header-form: `trigger-control: turn-complete` (with
optional `public-access-token`, `session-in-event-id`) and `trigger-control: upgrade-required`. The
TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in
`docs/ai-chat/client-protocol.mdx`) handle batch decoding and control-record filtering for you.
## Common mistakes
- **CRITICAL: sending a follow-up by re-POSTing `POST /api/v1/sessions`.**
```ts
// Wrong - a cached re-POST silently drops basePayload.message; basePayload is trigger config, not a channel
await fetch("/api/v1/sessions", { method: "POST", body: JSON.stringify({ ...createBody }) });
// Correct - append to the session's input channel
await fetch(`/realtime/v1/sessions/${id}/in/append`, { method: "POST", body: JSON.stringify({ kind: "message", payload }) });
```
- **Using the wrong token for `.in` / `.out`.** Use `publicAccessToken` from the create response
body (session-scoped). The `x-trigger-jwt` response header is run-scoped and cannot subscribe.
- **Initializing `chat.local` in `onChatStart`.** It is skipped on continuation runs, so `run()`
crashes with `chat.local can only be modified after initialization`. Init in `onBoot`.
- **`chat.defer` for the message-history write.** A mid-stream refresh would read `[]`. `await` that
write inline before the model streams; reserve `chat.defer` for analytics, audit, cache warming.
- **Giving the HITL tool an `execute`.** `streamText` calls it immediately. Leave it execute-less;
the frontend supplies the answer via `addToolOutput` + `sendAutomaticallyWhen`.
- **Declaring sub-agent / heavy tools only on `streamText`.** Also declare them on
`chat.agent({ tools })` (or pass to `convertToModelMessages(uiMessages, { tools })` in a custom
agent) so `toModelOutput` re-applies on every turn.
- **Importing heavy-execute tools into the Head Start route module.** This is a build-time import
chain problem; runtime strip helpers do not fix it. Keep schemas in an `ai` + `zod`-only module.
- **Returning a megabyte tool output on the stream.** One `tool-output-available` record over ~1 MiB
throws `ChatChunkTooLargeError`. Persist to your store, write the row first, then emit only an id.
- **Setting `X-Peek-Settled: 1` on the active-send path.** It races the new turn's first chunk and
closes the stream early. Use it only on reconnect-on-reload paths.
> Note on docs vocabulary: agent-side examples in some docs still use the legacy
> `trigger:turn-complete` chunk type. That is the agent-emit vocabulary. A custom **reader** must
> filter on the `trigger-control` header, not on `chunk.type`.
>
> MCP-driven agent chats (`list_agents`, `start_agent_chat`, `send_agent_message`,
> `close_agent_chat`) are MCP server tools used from Claude Code / Cursor, not importable SDK
> functions. See `/mcp-tools#agent-chat-tools`.
## References
- `trigger-authoring-chat-agent` skill - the everyday `chat.agent({...})` definition, lifecycle hooks, and
the `useTriggerChatTransport` happy path. Start there before reaching for this skill.
- `trigger-realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `trigger-authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/` (including `patterns/`). For HITL, sessions, and sub-agents start with `sessions.mdx`, `server-chat.mdx`, `client-protocol.mdx`, `patterns/human-in-the-loop.mdx`, `patterns/sub-agents.mdx`.
For `trigger.config.ts` and build extensions a chat-agent task may need (Prisma, Playwright, Python, etc.), read the bundled config docs under `@trigger.dev/sdk/docs/config/` (`config/extensions/` for the per-extension setup).
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,116 @@
---
name: trigger-cost-savings
description: >
Analyze Trigger.dev tasks, schedules, and runs for cost optimization opportunities. Use when
asked to reduce spend, optimize costs, audit usage, right-size machines, or review task
efficiency. Combines static source analysis with live run analysis via the Trigger.dev MCP
tools (list_runs, get_run_details, get_current_worker).
type: core
library: trigger.dev
sources:
- docs/how-to-reduce-your-spend.mdx
- docs/machines.mdx
- docs/runs/max-duration.mdx
- docs/queue-concurrency.mdx
- docs/idempotency.mdx
- docs/triggering.mdx
- docs/errors-retrying.mdx
- docs/limits.mdx
---
# Trigger.dev Cost Savings Analysis
Analyze task runs and configurations to find cost reduction opportunities. This skill pairs static source analysis with live run analysis via the Trigger.dev MCP server.
## Before you start: read the canonical guidance
The authoritative, version-pinned cost guidance ships beside this skill. Read it first so your recommendations match the installed SDK version:
- `@trigger.dev/sdk/docs/how-to-reduce-your-spend.mdx` — the canonical "reduce your spend" guide (machine sizing, idempotency de-dup, parallelism, retries, `maxDuration`, checkpointed waits, debounce).
- Supporting references: `@trigger.dev/sdk/docs/machines.mdx`, `runs/max-duration.mdx`, `queue-concurrency.mdx`, `idempotency.mdx`, `triggering.mdx` (debounce + batch), `errors-retrying.mdx` (`AbortTaskRunError`).
## Prerequisites: MCP tools
Live run analysis needs the **Trigger.dev MCP server**. Verify these tools are available:
- `list_runs` — list runs with filters (status, task, time period, machine size)
- `get_run_details` — get run logs, duration, and status
- `get_current_worker` — get registered tasks and their configurations
If they're not available, tell the user to install the MCP server:
```bash
npx trigger.dev@latest install-mcp
```
Without the MCP tools you can still do the static source analysis below; do not fabricate run data.
## Analysis workflow
### Step 1: Static analysis (source code)
Scan task files for:
1. **Oversized machines** — tasks on `large-1x`/`large-2x` without clear need.
2. **Missing `maxDuration`** — no execution-time limit (runaway-cost risk).
3. **Excessive retries**`maxAttempts` > 5 without `AbortTaskRunError` for known-permanent failures.
4. **Missing debounce** — high-frequency triggers without debounce.
5. **Missing idempotency** — payment/critical tasks without idempotency keys.
6. **Polling instead of waits**`setTimeout`/`setInterval`/sleep loops instead of `wait.for()`.
7. **Short waits**`wait.for()` under 5 seconds (not checkpointed, wastes compute).
8. **Sequential instead of batch** — multiple `triggerAndWait()` calls that could be `batchTriggerAndWait()`.
9. **Over-scheduled crons** — schedules firing more often than needed.
### Step 2: Run analysis (requires MCP tools)
- **2a. Expensive tasks** — `list_runs` over `period: "30d"`/`"7d"`; find high total compute (duration × count), high failure rates, and large machines with short durations (over-provisioned).
- **2b. Failure patterns** — `list_runs` with `status: "FAILED"`/`"CRASHED"`; separate transient (retryable) from permanent; suggest `AbortTaskRunError` for the latter; estimate wasted retry compute.
- **2c. Machine utilization** — `get_run_details` on sample runs; if a `large-2x` task consistently runs in under a second, or is I/O-bound (API/DB), it's over-provisioned.
- **2d. Schedule frequency** — `get_current_worker` to list cron patterns; flag schedules that are too frequent for their purpose.
### Step 3: Generate recommendations
Present a prioritized report with estimated impact:
```markdown
## Cost Optimization Report
### High impact
1. **Right-size `process-images`** — currently `large-2x`, average run 2s. `small-2x` could cut this task's cost by ~16x.
`machine: { preset: "small-2x" }` // was "large-2x"
### Medium impact
2. **Debounce `sync-user-data`** — 847 runs/day, often bursty.
`debounce: { key: \`user-${userId}\`, delay: "5s" }`
### Low impact / best practice
3. **Add `maxDuration` to `generate-report`** — no timeout configured.
`maxDuration: 300` // 5 minutes
```
## Machine preset costs (relative)
Larger machines cost proportionally more per second of compute:
| Preset | vCPU | RAM | Relative cost |
|--------|------|-----|---------------|
| micro | 0.25 | 0.25 GB | 0.25x |
| small-1x | 0.5 | 0.5 GB | 1x (baseline) |
| small-2x | 1 | 1 GB | 2x |
| medium-1x | 1 | 2 GB | 2x |
| medium-2x | 2 | 4 GB | 4x |
| large-1x | 4 | 8 GB | 8x |
| large-2x | 8 | 16 GB | 16x |
## Key principles
- **Waits > 5 seconds are free** — checkpointed, no compute charge.
- **Start small, scale up** — the default `small-1x` is right for most tasks.
- **I/O-bound tasks don't need big machines** — API calls and DB queries wait on the network.
- **Debounce saves the most on high-frequency tasks** — it consolidates bursts into single runs.
- **Idempotency prevents duplicate billed work** — especially for expensive operations.
- **`AbortTaskRunError` stops wasteful retries** — don't pay to retry permanent failures.
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full cost documentation ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,276 @@
---
name: trigger-realtime-and-frontend
description: >
Trigger.dev client/frontend surface: subscribe to runs in realtime
(runs.subscribeToRun and the @trigger.dev/react-hooks hook useRealtimeRun),
consume metadata and AI/text streams in React (useRealtimeStream), trigger
tasks from the browser (useTaskTrigger, useRealtimeTaskTrigger), and mint
scoped frontend credentials with auth.createPublicToken /
auth.createTriggerPublicToken.
Load when wiring a frontend (React/Next.js/Remix) or backend-for-frontend to
show live run progress, status badges, token streams, trigger buttons, or
wait-token approval UIs. NOT for writing the backend task itself (streams.define
/ metadata.set is trigger-authoring-tasks territory); this is the consumer side.
type: core
library: trigger.dev
sources:
- docs/realtime/overview.mdx
- docs/realtime/how-it-works.mdx
- docs/realtime/auth.mdx
- docs/realtime/run-object.mdx
- docs/realtime/react-hooks/overview.mdx
- docs/realtime/react-hooks/subscribe.mdx
- docs/realtime/react-hooks/triggering.mdx
- docs/realtime/react-hooks/streams.mdx
- docs/realtime/react-hooks/swr.mdx
- docs/realtime/react-hooks/use-wait-token.mdx
- docs/realtime/backend/subscribe.mdx
---
# Realtime and Frontend
The consumer side of Trigger.dev's run state and streams: read live run
updates, render AI/text streams, and trigger tasks from a browser. Hooks come
from `@trigger.dev/react-hooks`; token minting and backend subscription come
from `@trigger.dev/sdk`.
## Setup
```bash
npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix)
# @trigger.dev/sdk is already installed for the backend
```
The flow is always: mint a scoped token in the backend, pass it to the
frontend, subscribe with a hook.
```ts
// backend (API route / server action)
import { auth } from "@trigger.dev/sdk";
const publicAccessToken = await auth.createPublicToken({
scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless
});
```
```tsx
// frontend
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
return <div>Run: {run.status}</div>;
}
```
There are two token kinds: Public Access Tokens (read/subscribe, from
`auth.createPublicToken`) and Trigger Tokens (trigger-from-browser, single-use,
from `auth.createTriggerPublicToken`). Both default to a 15 minute expiry.
## Core patterns
### 1. Subscribe to a run and render metadata progress
`metadata` is `Record<string, DeserializedJson>`, so nested values need a cast.
```tsx
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Progress({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun<typeof myTask>(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
const progress = run.metadata?.progress as { percentage?: number } | undefined;
return <div>{run.status}: {progress?.percentage ?? 0}%</div>;
}
```
Pass `onComplete: (run, error) => {}` to react when the run finishes.
### 2. Status-only subscription with `skipColumns`
For a badge or progress bar you do not need `payload`/`output`. Skipping them
reduces wire size and avoids "Large HTTP Payload" warnings.
```tsx
const { run } = useRealtimeRun(runId, {
accessToken: publicAccessToken,
skipColumns: ["payload", "output"],
});
```
You can skip any of: `payload`, `output`, `metadata`, `startedAt`, `delayUntil`,
`queuedAt`, `expiredAt`, `completedAt`, `number`, `isTest`, `usageDurationMs`,
`costInCents`, `baseCostInCents`, `ttl`, `payloadType`, `outputType`, `runTags`,
`error`.
### 3. Trigger from the browser with a Trigger Token
`accessToken` here is a Trigger Token (`auth.createTriggerPublicToken`), not a
Public Access Token.
```tsx
"use client";
import { useTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function TriggerButton({ triggerToken }: { triggerToken: string }) {
const { submit, handle, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
accessToken: triggerToken,
});
if (handle) return <div>Run ID: {handle.id}</div>;
return (
<button onClick={() => submit({ foo: "bar" }, { tags: ["user:123"] })} disabled={isLoading}>
{isLoading ? "Triggering..." : "Run"}
</button>
);
}
```
`submit(payload, options?)` takes the same options as a backend `trigger` call.
### 4. Trigger and subscribe in one hook
```tsx
"use client";
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Runner({ publicAccessToken }: { publicAccessToken: string }) {
const { submit, run, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
accessToken: publicAccessToken,
});
if (run) return <div>{run.status}</div>;
return <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>Run</button>;
}
```
Use `useRealtimeTaskTriggerWithStreams<typeof myTask, STREAMS>` when you also
want the task's streams (it returns `{ submit, run, streams, error, isLoading }`).
### 5. Consume an AI/text stream (SDK 4.1.0+, recommended)
`useRealtimeStream` takes a defined stream for full type safety, or a `runId`
plus optional stream key. Returns `{ parts, error }`.
```tsx
"use client";
import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/trigger/streams"; // a defined stream -> typed parts
export function StreamView({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { parts, error } = useRealtimeStream(aiStream, runId, {
accessToken: publicAccessToken,
timeoutInSeconds: 300, // default 60
onData: (chunk) => console.log(chunk),
});
if (error) return <div>Error: {error.message}</div>;
if (!parts) return <div>Loading...</div>;
return <div>{parts.join("")}</div>;
}
```
Without a defined stream: `useRealtimeStream<string>(runId, "ai-output", { accessToken })`,
or omit the key to use the default stream. Other options: `baseURL`, `startIndex`,
`throttleInMs` (default 16). The legacy `useRealtimeRunWithStreams(runId, options)`
hook is still supported when you need both the run and all its streams at once.
### 6. Send input back into a running task
```tsx
"use client";
import { useInputStreamSend } from "@trigger.dev/react-hooks";
import { approval } from "@/trigger/streams";
export function ApprovalForm({ runId, accessToken }: { runId: string; accessToken: string }) {
const { send, isLoading, isReady } = useInputStreamSend(approval.id, runId, { accessToken });
return (
<button disabled={!isReady || isLoading} onClick={() => send({ approved: true })}>
Approve
</button>
);
}
```
### 7. Complete a wait token from React
```ts
// backend: create the token, return id + publicAccessToken to the frontend
import { wait } from "@trigger.dev/sdk";
const token = await wait.createToken({ timeout: "10m" });
return { tokenId: token.id, publicToken: token.publicAccessToken };
```
```tsx
"use client";
import { useWaitToken } from "@trigger.dev/react-hooks";
export function Approve({ tokenId, publicToken }: { tokenId: string; publicToken: string }) {
const { complete } = useWaitToken(tokenId, { accessToken: publicToken });
return <button onClick={() => complete({ approved: true })}>Approve</button>;
}
```
### 8. Subscribe from the backend (async iterators)
```ts
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";
const handle = await tasks.trigger("my-task", { some: "data" });
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
console.log(run.payload.some, run.output?.some); // typed
}
```
`runs.subscribeToRun` completes when the run finishes, so the loop exits on its own.
## Common mistakes
1. **CRITICAL: Triggering from the browser with a Public Access Token.** The
read token from `createPublicToken` cannot trigger tasks.
- Wrong: `useTaskTrigger("my-task", { accessToken: publicAccessTokenFromCreatePublicToken })`
- Correct: mint a single-use Trigger Token with `auth.createTriggerPublicToken("my-task")` and pass that.
2. **Token with no scopes.** A scopeless token authorizes nothing, so every subscribe 403s.
- Wrong: `await auth.createPublicToken()`
- Correct: `await auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })`
3. **Polling with `useRun`/SWR for live updates.** `useRun` is the SWR-based
management-API hook (not recommended for live state); set `refreshInterval: 0`
to stop polling if you do use it.
- Wrong: `useRun(runId, { refreshInterval: 1000 })` to track progress
- Correct: `useRealtimeRun(runId, { accessToken })` (no polling, no WebSocket setup)
4. **Forgetting `"use client"`.** Realtime/trigger hooks cannot run in a server component.
- Wrong: a Next.js App Router server component using `useRealtimeRun`
- Correct: put `"use client";` at the top of any component using these hooks.
5. **Shipping `payload`/`output` you do not render.**
- Wrong: `useRealtimeRun(runId, { accessToken })` for a status badge (large payloads over the wire)
- Correct: `useRealtimeRun(runId, { accessToken, skipColumns: ["payload", "output"] })`
6. **Subscribing before the handle exists.**
- Wrong: `useRealtimeRun(handle, { accessToken: handle?.publicAccessToken })` with no guard
- Correct: add `enabled: !!handle` so it subscribes only once the trigger returns a handle.
## References
Sibling skills:
- `trigger-authoring-tasks` for the task side: `streams.define()`, `metadata.set()`, and `wait.createToken`.
- `trigger-authoring-chat-agent` and `trigger-chat-agent-advanced` for chat agents, which build on these realtime streams.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with:
- `@trigger.dev/sdk/docs/realtime/react-hooks/subscribe.mdx`
- `@trigger.dev/sdk/docs/realtime/react-hooks/streams.mdx`
- `@trigger.dev/sdk/docs/realtime/auth.mdx`
- `@trigger.dev/sdk/docs/realtime/run-object.mdx` (the realtime run object differs from the management-API object returned by `useRun`)
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,26 @@
// CJS variant of ./ai-runtime.ts — tshy swaps this in for the CommonJS build.
// `require("ai")` of an ESM-only package is supported on Node >=20.19 / >=22.12.
// @ts-ignore
const ai = require("ai");
// @ts-ignore
module.exports.convertToModelMessages = ai.convertToModelMessages;
// @ts-ignore
module.exports.dynamicTool = ai.dynamicTool;
// @ts-ignore
module.exports.generateId = ai.generateId;
// @ts-ignore
module.exports.getToolName = ai.getToolName;
// @ts-ignore
module.exports.isToolUIPart = ai.isToolUIPart;
// @ts-ignore
module.exports.jsonSchema = ai.jsonSchema;
// @ts-ignore
module.exports.readUIMessageStream = ai.readUIMessageStream;
// @ts-ignore
module.exports.stepCountIs = ai.stepCountIs;
// @ts-ignore
module.exports.tool = ai.tool;
// @ts-ignore
module.exports.zodSchema = ai.zodSchema;
@@ -0,0 +1,39 @@
// Runtime VALUE imports from `ai`, isolated behind a paired ESM/CJS shim.
//
// `ai@7` is ESM-only (no `require` export). Under NodeNext + TS < 5.8 a value
// import of an ESM-only package emitted to a CJS file raises TS1479, which
// would break the SDK's CommonJS build. tshy maps `ai-runtime-cjs.cts` -> the
// CJS build and this `.ts` -> the ESM build, so each dialect gets the right
// form. `require(esm)` is stable on Node >=20.19 / >=22.12 (both our targets),
// so the CJS variant works at runtime. Mirrors `imports/uncrypto{,-cjs.cts}`.
//
// VALUES only — type-only imports from `ai` erase and don't trip TS1479, so
// they stay as direct `import type { … } from "ai"` at their use sites.
// @ts-ignore
import {
convertToModelMessages,
dynamicTool,
generateId,
getToolName,
isToolUIPart,
jsonSchema,
readUIMessageStream,
stepCountIs,
tool,
zodSchema,
} from "ai";
// @ts-ignore
export {
convertToModelMessages,
dynamicTool,
generateId,
getToolName,
isToolUIPart,
jsonSchema,
readUIMessageStream,
stepCountIs,
tool,
zodSchema,
};
@@ -0,0 +1,5 @@
// @ts-ignore
const { subtle } = require("uncrypto");
// @ts-ignore
module.exports.subtle = subtle;
@@ -0,0 +1,5 @@
// @ts-ignore
import { subtle } from "uncrypto";
// @ts-ignore
export { subtle };
@@ -0,0 +1,166 @@
import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as nodePath from "node:path";
/**
* Server-only runtime for the auto-injected skill tools
* (`loadSkill` / `readFile` / `bash`) that `chat.agent({ skills })`
* wires up. Split off from `./ai.ts` so the chat-agent surface in
* `@trigger.dev/sdk/ai` stays importable from client bundles —
* Next.js + Webpack reject top-level `node:*` imports anywhere in a
* client graph, even when a consumer only pulls in types.
*
* The SDK's `ai.ts` loads this module via a computed-string dynamic
* import inside each tool's `execute` — webpack treats the
* expression as an unknown dependency and skips static tracing, so
* the node-only symbols here never surface in a client build. The
* module resolves fine at runtime on a server worker because the
* relative path (`./agentSkillsRuntime.js`) lands next to `ai.js` in
* the emitted dist.
*
* Public subpath: `@trigger.dev/sdk/ai/skills-runtime`. Customers
* who want to eagerly bundle the runtime server-side (e.g. warming
* it on worker bootstrap) can import from there.
*/
const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024;
const DEFAULT_READ_FILE_BYTES = 1024 * 1024;
export type BashSkillInput = {
/** Absolute path to the skill's root (used as `cwd`). */
skillPath: string;
/** The bash command to run. */
command: string;
/** Optional abort signal forwarded to `spawn()`. */
abortSignal?: AbortSignal;
};
export type BashSkillResult =
| { exitCode: number | null; stdout: string; stderr: string }
| { error: string };
export type ReadFileInSkillInput = {
/** Absolute path to the skill's root — the relative path must resolve inside it. */
skillPath: string;
/** Relative path the tool caller supplied. */
relativePath: string;
};
export type ReadFileInSkillResult = { content: string } | { error: string };
function truncate(s: string, limit: number): string {
if (s.length <= limit) return s;
return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`;
}
/**
* Path-traversal guard: confirm `relative` resolves inside `root`,
* even after symlinks are followed. Throws if it escapes via `..`, an
* absolute prefix, or a symlink that points outside. Returns the
* resolved real path.
*
* `fs.realpath` only works on paths that exist, so when the resolved
* path doesn't exist yet (e.g. writing a new file) we fall back to
* the lexical check — a non-existent path can't traverse a symlink
* to escape since the symlink doesn't exist either.
*/
async function safeJoinInside(root: string, relative: string): Promise<string> {
if (nodePath.isAbsolute(relative)) {
throw new Error(`Path must be relative to the skill directory: ${relative}`);
}
const realRoot = await fs.realpath(nodePath.resolve(root));
const resolved = nodePath.resolve(realRoot, relative);
let real = resolved;
try {
real = await fs.realpath(resolved);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
// Path doesn't exist yet; fall through with the lexical resolve.
}
const normalized = realRoot + nodePath.sep;
if (real !== realRoot && !real.startsWith(normalized)) {
throw new Error(`Path escapes the skill directory: ${relative}`);
}
return real;
}
export async function readFileInSkill({
skillPath,
relativePath,
}: ReadFileInSkillInput): Promise<ReadFileInSkillResult> {
let absolute: string;
try {
absolute = await safeJoinInside(skillPath, relativePath);
} catch (err) {
return { error: (err as Error).message };
}
try {
const content = await fs.readFile(absolute, "utf8");
return { content: truncate(content, DEFAULT_READ_FILE_BYTES) };
} catch (err) {
return { error: (err as Error).message };
}
}
export async function runBashInSkill({
skillPath,
command,
abortSignal,
}: BashSkillInput): Promise<BashSkillResult> {
return new Promise<BashSkillResult>((resolvePromise) => {
let child;
try {
child = spawn("bash", ["-c", command], {
cwd: skillPath,
signal: abortSignal,
});
} catch (err) {
resolvePromise({ error: (err as Error).message });
return;
}
// Cap stdout/stderr accumulation at the byte budget so an
// LLM-generated command (`cat /dev/zero`, `yes`) can't OOM the
// worker. Track total seen length separately so the truncation
// notice still reports how much was dropped.
let stdout = "";
let stderr = "";
let stdoutSeen = 0;
let stderrSeen = 0;
const limit = DEFAULT_BASH_OUTPUT_BYTES;
child.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stdoutSeen += text.length;
if (stdout.length >= limit) return;
const remaining = limit - stdout.length;
stdout += text.length > remaining ? text.slice(0, remaining) : text;
});
child.stderr?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stderrSeen += text.length;
if (stderr.length >= limit) return;
const remaining = limit - stderr.length;
stderr += text.length > remaining ? text.slice(0, remaining) : text;
});
child.once("close", (code: number | null) => {
const stdoutFinal =
stdoutSeen > stdout.length
? `${stdout}\n…[truncated ${stdoutSeen - stdout.length} bytes]`
: stdout;
const stderrFinal =
stderrSeen > stderr.length
? `${stderr}\n…[truncated ${stderrSeen - stderr.length} bytes]`
: stderr;
resolvePromise({
exitCode: code,
stdout: stdoutFinal,
stderr: stderrFinal,
});
});
child.once("error", (err: Error) => {
resolvePromise({ error: err.message });
});
});
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Browser-safe primitives shared between `@trigger.dev/sdk/ai` (server) and
* `@trigger.dev/sdk/chat` / `@trigger.dev/sdk/chat/react` (client).
*
* This module exists to keep `ai.ts` reachable only from the server graph.
* `ai.ts` weighs in at ~7000 lines and statically imports the agent-skills
* runtime (which uses `node:child_process` / `node:fs/promises`). When a
* browser bundle imports a runtime value from `ai.ts` — historically the
* `PENDING_MESSAGE_INJECTED_TYPE` constant in `chat-react.ts` — the bundler
* traces `ai.ts`'s entire module graph into the client chunk and hits the
* `node:` builtins, which Turbopack rejects outright (and webpack flags as
* a "Critical dependency" warning).
*
* Anything in this file MUST stay free of `node:*` imports and free of any
* import from `ai.ts`.
*/
import type { Task, AnyTask } from "@trigger.dev/core/v3";
import type { InferUITools, ToolSet, UIDataTypes, UIMessage } from "ai";
/**
* Message-part `type` value for the pending-message data part the agent
* injects when a follow-up message arrives mid-turn.
*/
export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as const;
// Declared in `chat.ts` (a public subpath) so customer declaration emit can
// name them — declaring them here breaks `declaration: true` consumers with
// TS2742, since this module isn't reachable via the package exports map.
import type { ChatTaskWirePayload } from "./chat.js";
export type { ChatTaskWirePayload, ChatInputChunk } from "./chat.js";
/**
* Extracts the client-data (`metadata`) type from a chat task.
*
* @example
* ```ts
* import type { InferChatClientData } from "@trigger.dev/sdk/ai";
* import type { myChat } from "@/trigger/chat";
*
* type MyClientData = InferChatClientData<typeof myChat>;
* ```
*/
export type InferChatClientData<TTask extends AnyTask> =
TTask extends Task<string, ChatTaskWirePayload<any, infer TMetadata>, any> ? TMetadata : unknown;
/**
* Extracts the UI message type from a chat task (wire payload `message` items).
*
* @example
* ```ts
* import type { InferChatUIMessage } from "@trigger.dev/sdk/ai";
* import type { myChat } from "@/trigger/chat";
*
* type Msg = InferChatUIMessage<typeof myChat>;
* ```
*/
export type InferChatUIMessage<TTask extends AnyTask> =
TTask extends Task<string, ChatTaskWirePayload<infer TUIM extends UIMessage, any>, any>
? TUIM
: UIMessage;
/**
* Derive the chat `UIMessage` type for a given tool set. The tool-part types
* (`tool-${name}` with typed input/output) are inferred from the tools. Use
* this to declare the message type from your tools (e.g. to pass to
* `chat.withUIMessage<...>()` or to type the frontend) without hand-writing
* the `UIMessage<unknown, UIDataTypes, InferUITools<...>>` triple.
*
* @example
* ```ts
* import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
* const tools = { search, readFile };
* type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
* ```
*/
export type InferChatUIMessageFromTools<TTools extends ToolSet> = UIMessage<
unknown,
UIDataTypes,
InferUITools<TTools>
>;
/**
* Upsert an incoming wire message into the customer's DB-backed chain
* inside a `hydrateMessages` hook. Returns `true` iff the chain was
* mutated (the caller should persist).
*
* Handles the three cases that matter:
*
* - **Non-submit-message trigger** (`regenerate-message` / `action`,
* or `submit-message` with no incoming): no-op. Returns `false`.
* - **Incoming id already in `stored`** (HITL `addToolOutput` /
* `addToolApproveResponse` continuation — the wire carries the
* existing assistant's id with a slim resolution payload): no-op.
* The runtime's per-turn merge overlays the new tool-state advance
* onto the existing entry; pushing again would duplicate the row
* in the chain you return, and the duplicate slim copy would hit
* `toModelMessages` with no `input`. Returns `false`.
* - **Incoming id not in `stored`** (typically a fresh user message
* on a new turn): push. Returns `true`.
*
* Mutates `stored` in place. The caller persists `stored`, not the
* return value.
*
* @example
* ```ts
* import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
*
* chat.agent({
* hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
* const record = await db.chat.findUnique({ where: { id: chatId } });
* const stored = record?.messages ?? [];
* if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
* await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
* }
* return stored;
* },
* });
* ```
*/
export function upsertIncomingMessage<TMsg extends UIMessage = UIMessage>(
stored: TMsg[],
event: {
trigger: "submit-message" | "regenerate-message" | "action";
incomingMessages: TMsg[];
}
): boolean {
if (event.trigger !== "submit-message") return false;
if (event.incomingMessages.length === 0) return false;
const newMsg = event.incomingMessages[event.incomingMessages.length - 1];
if (!newMsg) return false;
if (newMsg.id) {
const existingIdx = stored.findIndex((m) => m.id === newMsg.id);
if (existingIdx !== -1) return false;
}
stored.push(newMsg);
return true;
}
/**
* Tool-part states that the client advances and ships back over the wire.
* Covers HITL `addToolOutput` (output-available / output-error) and the
* approval flow (approval-responded / output-denied). `input-streaming` /
* `input-available` / `approval-requested` are server-emitted only — if
* we see them on the wire we treat them as no-ops and skip the slim/merge.
*/
function isWireAdvanceableToolState(
state: unknown
): state is "output-available" | "output-error" | "approval-responded" | "output-denied" {
return (
state === "output-available" ||
state === "output-error" ||
state === "approval-responded" ||
state === "output-denied"
);
}
/** Whether a tool-UI part is a static (`tool-${name}`) or dynamic tool. */
function isToolPartType(type: unknown): boolean {
return typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
}
/**
* Slim an outgoing assistant message before it ships on `submit-message`.
*
* When the client calls `addToolOutput(...)` to resolve a HITL tool (or
* `addToolApproveResponse(...)` to approve/deny one), the AI SDK turns
* it into a `submit-message` whose `messages.at(-1)` is the existing
* assistant message with the new state stitched onto a single tool
* part. On a reasoning-heavy multi-step turn, that full assistant
* message can be 600 KB 1 MB (encrypted reasoning blobs, reasoning
* text, full tool `input` JSON, prior tool outputs) — well over the
* `.in/append` cap.
*
* The agent runtime only consumes the wire-advanced fields of those
* tool parts (state + output / errorText / approval). Everything else
* (text, reasoning, tool `input`) is rebuilt server-side from the
* durable snapshot or `hydrateMessages`. So we drop everything but
* the advanced tool parts here, and reduce those to just the fields
* the server overlays.
*
* The slim only fires when the assistant message carries at least one
* wire-advanceable tool part. Plain assistant resends (no resolved /
* approval-responded tool) and non-assistant messages pass through
* untouched.
*
* Pairs with the per-turn merge on the agent side
* (`mergeIncomingIntoHydrated` in `ai.ts`).
*/
export function slimSubmitMessageForWire<TMsg extends UIMessage | undefined>(message: TMsg): TMsg {
if (!message) return message;
if (message.role !== "assistant") return message;
const parts = (message.parts ?? []) as any[];
const advancedToolParts = parts.filter(
(p) =>
p && typeof p === "object" && isToolPartType(p.type) && isWireAdvanceableToolState(p.state)
);
if (advancedToolParts.length === 0) return message;
const slimParts = advancedToolParts.map((p: any) => {
const base: Record<string, unknown> = {
type: p.type,
toolCallId: p.toolCallId,
state: p.state,
};
if (p.type === "dynamic-tool" && typeof p.toolName === "string") {
base.toolName = p.toolName;
}
if (p.state === "output-available") {
base.output = p.output;
if (p.approval !== undefined) base.approval = p.approval;
} else if (p.state === "output-error") {
if (p.errorText !== undefined) base.errorText = p.errorText;
if (p.approval !== undefined) base.approval = p.approval;
} else if (p.state === "approval-responded" || p.state === "output-denied") {
if (p.approval !== undefined) base.approval = p.approval;
}
return base;
});
return {
id: message.id,
role: message.role,
parts: slimParts,
} as unknown as TMsg;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
/**
* Auto-register `@ai-sdk/otel` so AI SDK 7 emits OpenTelemetry spans into the
* Trigger.dev run trace with no customer setup.
*
* AI SDK 6 emitted spans from `ai` core, so `experimental_telemetry` (set by
* `chat.toStreamTextOptions({ telemetry })`) was enough. v7 moved span emission
* into the separate `@ai-sdk/otel` adapter, so on v7 `experimental_telemetry`
* alone produces nothing until an integration is registered. We register it once
* per worker process at chat.agent run boot. `@ai-sdk/otel` writes to the global
* OpenTelemetry tracer, which is the same provider the Trigger worker installs
* (the `@opentelemetry/api` global is a `globalThis` singleton keyed by major
* version, so the separate copies still share it), so spans land in the trace.
*
* Fully guarded and best-effort — telemetry must never break a run:
* - `registerTelemetry` only exists in v7 `ai` (no-op on v5/v6).
* - `@ai-sdk/otel` is an OPTIONAL peer. The specifier is computed so the task
* bundler doesn't hard-require it (v5/v6 users never install it).
* - We detect an already-registered `@ai-sdk/otel` integration and skip, so a
* customer (or a library they import) that registers it themselves doesn't
* get duplicate spans. `registerTelemetry` is append-only, so without this
* guard a second integration would double every span.
* - To disable our auto-register entirely (e.g. you register `@ai-sdk/otel`
* yourself after this boot, or via a custom integration our detection can't
* see), set the env var `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0`.
*/
let registration: Promise<void> | null = null;
/** Registers the AI SDK OTel integration once per process. Safe to call on every run. */
export function ensureAiSdkTelemetry(): Promise<void> {
if (!registration) {
registration = register();
}
return registration;
}
async function register(): Promise<void> {
try {
if (isAutoRegisterDisabled()) {
return; // opted out via TRIGGER_AI_SDK_OTEL_AUTOREGISTER
}
const aiMod: any = await import("ai");
if (typeof aiMod.registerTelemetry !== "function") {
return; // v5 / v6 — `ai` core emits spans itself, nothing to wire.
}
// Computed specifier keeps the optional peer out of static bundler
// resolution; resolves at runtime only when the customer installed it.
const otelSpecifier = ["@ai-sdk", "otel"].join("/");
const otelMod: any = await import(otelSpecifier).catch(() => null);
if (typeof otelMod?.OpenTelemetry !== "function") {
return; // optional peer not installed
}
if (hasAiSdkOtelIntegration(otelMod.OpenTelemetry)) {
return; // already registered by the customer or a library they import
}
aiMod.registerTelemetry(new otelMod.OpenTelemetry());
} catch {
// never throw from telemetry setup
}
}
function isAutoRegisterDisabled(): boolean {
const value = process.env.TRIGGER_AI_SDK_OTEL_AUTOREGISTER?.toLowerCase();
return value === "0" || value === "false";
}
/**
* True if an `@ai-sdk/otel` integration is already in v7's global telemetry
* registry (`globalThis.AI_SDK_TELEMETRY_INTEGRATIONS`, a documented public
* global that `registerTelemetry` appends to). `instanceof` matches a same-copy
* registration; the constructor-name fallback catches a separate copy of
* `@ai-sdk/otel`.
*/
function hasAiSdkOtelIntegration(OpenTelemetry: any): boolean {
const integrations = (globalThis as any).AI_SDK_TELEMETRY_INTEGRATIONS;
if (!Array.isArray(integrations)) {
return false;
}
return integrations.some(
(integration: any) =>
(typeof OpenTelemetry === "function" && integration instanceof OpenTelemetry) ||
integration?.constructor?.name === "OpenTelemetry"
);
}
+421
View File
@@ -0,0 +1,421 @@
import {
type RealtimeRunSkipColumns,
type ApiClientConfiguration,
apiClientManager,
generateJWT as internal_generateJWT,
} from "@trigger.dev/core/v3";
import "@trigger.dev/core/v3/sdk-scope-storage";
/**
* Register the global API client configuration. Alternatively, you can set the `TRIGGER_SECRET_KEY` and `TRIGGER_API_URL` environment variables.
* @param options The API client configuration.
* @param options.baseURL The base URL of the Trigger API. (default: `https://api.trigger.dev`)
* @param options.accessToken The accessToken to authenticate with the Trigger API. (default: `process.env.TRIGGER_SECRET_KEY`) This can be found in your Trigger.dev project "API Keys" settings.
*
* @example
*
* ```typescript
* import { configure } from "@trigger.dev/sdk/v3";
*
* configure({
* baseURL: "https://api.trigger.dev",
* accessToken: "tr_dev_1234567890"
* });
* ```
*/
export function configure(options: ApiClientConfiguration) {
apiClientManager.setGlobalAPIClientConfiguration(options);
}
export const auth = {
configure,
createPublicToken,
createTriggerPublicToken,
createBatchTriggerPublicToken,
withAuth,
withPublicToken,
withTriggerPublicToken,
withBatchTriggerPublicToken,
};
type PublicTokenPermissionProperties = {
/**
* Grant access to specific tasks
*/
tasks?: string | string[];
/**
* Grant access to specific run tags
*/
tags?: string | string[];
/**
* Grant access to specific runs
*/
runs?: string | string[] | true;
/**
* Grant access to specific batch runs
*/
batch?: string | string[];
/**
* Grant access to specific waitpoints
*/
waitpoints?: string | string[];
/**
* Grant access to send data to input streams on specific runs
*/
inputStreams?: string | string[];
/**
* Grant access to specific Sessions (the durable, typed I/O primitive that
* outlives a single run). Use the session's friendlyId (e.g. `session_abc`).
*
* `read:sessions:{id}` lets the bearer read both the `.out` and `.in`
* channels and list runs on the session. `write:sessions:{id}` lets the
* bearer append to the session's channels and create new runs against it.
*/
sessions?: string | string[];
};
export type PublicTokenPermissions = {
read?: PublicTokenPermissionProperties;
write?: PublicTokenPermissionProperties;
/**
* Use auth.createTriggerPublicToken
*/
trigger?: {
tasks: string | string[];
};
/**
* Use auth.createBatchTriggerPublicToken
*/
batchTrigger?: {
tasks: string | string[];
};
};
export type CreatePublicTokenOptions = {
/**
* A collection of permission scopes to be granted to the token.
*
* @example
*
* ```typescript
* scopes: {
* read: {
* tags: ["file:1234"]
* }
* }
* ```
*/
scopes?: PublicTokenPermissions;
/**
* The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string.
*
* @example
*
* ```typescript
* expirationTime: "1h"
* ```
*/
expirationTime?: number | Date | string;
realtime?: {
/**
* Skip columns from the subscription.
*
* @default []
*
* @example
* ```ts
* auth.createPublicToken({
* realtime: {
* skipColumns: ["payload", "output"]
* }
* });
* ```
*/
skipColumns?: RealtimeRunSkipColumns;
};
};
/**
* Creates a public token using the provided options.
*
* @param options - Optional parameters for creating the public token.
* @param options.scopes - An array of permission scopes to be included in the token.
* @param options.expirationTime - The expiration time for the token.
* @param options.realtime - Options for realtime subscriptions.
* @param options.realtime.skipColumns - Skip columns from the subscription.
* @returns A promise that resolves to a string representing the generated public token.
*
* @example
*
* ```typescript
* import { auth } from "@trigger.dev/sdk/v3";
*
* const publicToken = await auth.createPublicToken({
* scopes: {
* read: {
* tags: ["file:1234"]
* }
* });
* ```
*/
async function createPublicToken(options?: CreatePublicTokenOptions): Promise<string> {
const apiClient = apiClientManager.clientOrThrow();
const claims = await apiClient.generateJWTClaims();
return await internal_generateJWT({
secretKey: apiClient.accessToken,
payload: {
...claims,
scopes: options?.scopes ? flattenScopes(options.scopes) : undefined,
realtime: options?.realtime,
},
expirationTime: options?.expirationTime,
});
}
/**
* Executes a function with a public token, providing temporary access permissions.
*
* @param options - Options for creating the public token.
* @param fn - The asynchronous function to be executed with the public token.
*/
async function withPublicToken(options: CreatePublicTokenOptions, fn: () => Promise<void>) {
const token = await createPublicToken(options);
await withAuth({ accessToken: token }, fn);
}
export type CreateTriggerTokenOptions = {
/**
* The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string.
*
* @example
*
* ```typescript
* expirationTime: "1h"
* ```
*/
expirationTime?: number | Date | string;
/**
* Whether the token can be used multiple times. By default trigger tokens are one-time use.
* @default false
*/
multipleUse?: boolean;
realtime?: {
/**
* Skip columns from the subscription.
*
* @default []
*
* @example
* ```ts
* auth.createTriggerPublicToken("my-task", {
* realtime: {
* skipColumns: ["payload", "output"]
* }
* });
* ```
*/
skipColumns?: RealtimeRunSkipColumns;
};
};
/**
* Creates a one-time use token to trigger a specific task.
*
* @param task - The task ID or an array of task IDs that the token should allow triggering.
* @param options - Options for creating the one-time use token.
* @returns A promise that resolves to a string representing the generated one-time use token.
*
* @example
* Create a one-time use public token that allows triggering a specific task:
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createTriggerPublicToken("my-task");
* ```
*
* @example You can also create a one-time use token that allows triggering multiple tasks:
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createTriggerPublicToken(["task1", "task2"]);
* ```
*
* @example You can also create a one-time use token that allows triggering a task with a specific expiration time:
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createTriggerPublicToken("my-task", { expirationTime: "1h" });
* ```
*/
async function createTriggerPublicToken(
task: string | string[],
options?: CreateTriggerTokenOptions
): Promise<string> {
const apiClient = apiClientManager.clientOrThrow();
const claims = await apiClient.generateJWTClaims();
return await internal_generateJWT({
secretKey: apiClient.accessToken,
payload: {
...claims,
otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true,
realtime: options?.realtime,
scopes: flattenScopes({
trigger: {
tasks: task,
},
}),
},
expirationTime: options?.expirationTime,
});
}
/**
* Executes a function with a one-time use token that allows triggering a specific task.
*
* @param task - The task ID or an array of task IDs that the token should allow triggering.
* @param options - Options for creating the one-time use token.
* @param fn - The asynchronous function to be executed with the one-time use token.
*/
async function withTriggerPublicToken(
task: string | string[],
options: CreateTriggerTokenOptions = {},
fn: () => Promise<void>
) {
const token = await createTriggerPublicToken(task, options);
await withAuth({ accessToken: token }, fn);
}
/**
* Creates a one-time use token to batch trigger a specific task or tasks.
*
* @param task - The task ID or an array of task IDs that the token should allow triggering.
* @param options - Options for creating the one-time use token.
* @returns A promise that resolves to a string representing the generated one-time use token.
*
* @example
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createBatchTriggerPublicToken("my-task");
* ```
*
* @example You can also create a one-time use token that allows batch triggering multiple tasks:
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createBatchTriggerPublicToken(["task1", "task2"]);
* ```
*
* @example You can also create a one-time use token that allows batch triggering a task with a specific expiration time:
*
* ```ts
* import { auth } from "@trigger.dev/sdk/v3";
*
* const token = await auth.createBatchTriggerPublicToken("my-task", { expirationTime: "1h" });
* ```
*/
async function createBatchTriggerPublicToken(
task: string | string[],
options?: CreateTriggerTokenOptions
): Promise<string> {
const apiClient = apiClientManager.clientOrThrow();
const claims = await apiClient.generateJWTClaims();
return await internal_generateJWT({
secretKey: apiClient.accessToken,
payload: {
...claims,
otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true,
realtime: options?.realtime,
scopes: flattenScopes({
batchTrigger: {
tasks: task,
},
}),
},
expirationTime: options?.expirationTime,
});
}
/**
* Executes a function with a one-time use token that allows triggering a specific task.
*
* @param task - The task ID or an array of task IDs that the token should allow triggering.
* @param options - Options for creating the one-time use token.
* @param fn - The asynchronous function to be executed with the one-time use token.
*/
async function withBatchTriggerPublicToken(
task: string | string[],
options: CreateTriggerTokenOptions = {},
fn: () => Promise<void>
) {
const token = await createBatchTriggerPublicToken(task, options);
await withAuth({ accessToken: token }, fn);
}
/**
* Executes a provided asynchronous function with a specified API client configuration.
*
* @template R - The type of the asynchronous function to be executed.
* @param {ApiClientConfiguration} config - The configuration for the API client.
* @param {R} fn - The asynchronous function to be executed.
* @returns {Promise<ReturnType<R>>} A promise that resolves to the return type of the provided function.
*/
async function withAuth<R extends (...args: any[]) => Promise<any>>(
config: ApiClientConfiguration,
fn: R
): Promise<ReturnType<R>> {
return apiClientManager.runWithConfig(config, fn);
}
function flattenScopes(permissions: PublicTokenPermissions): string[] {
const flattenedPermissions: string[] = [];
for (const [action, properties] of Object.entries(permissions)) {
if (properties) {
if (typeof properties === "boolean" && properties) {
flattenedPermissions.push(action);
} else if (typeof properties === "object") {
for (const [property, value] of Object.entries(properties)) {
if (Array.isArray(value)) {
for (const item of value) {
flattenedPermissions.push(`${action}:${property}:${item}`);
}
} else if (typeof value === "string") {
flattenedPermissions.push(`${action}:${property}:${value}`);
} else if (typeof value === "boolean" && value) {
flattenedPermissions.push(`${action}:${property}`);
}
}
}
}
}
return flattenedPermissions;
}
+64
View File
@@ -0,0 +1,64 @@
import type { ApiPromise, ApiRequestOptions, RetrieveBatchV2Response } from "@trigger.dev/core/v3";
import { accessoryAttributes, apiClientManager, mergeRequestOptions } from "@trigger.dev/core/v3";
import {
batchTriggerAndWaitTasks,
batchTriggerById,
batchTriggerByIdAndWait,
batchTriggerTasks,
} from "./shared.js";
import { tracer } from "./tracer.js";
export const batch = {
trigger: batchTriggerById,
triggerAndWait: batchTriggerByIdAndWait,
triggerByTask: batchTriggerTasks,
triggerByTaskAndWait: batchTriggerAndWaitTasks,
retrieve: retrieveBatch,
};
/**
* Retrieves details about a specific batch by its ID.
*
* @param {string} batchId - The unique identifier of the batch to retrieve
* @param {ApiRequestOptions} [requestOptions] - Optional API request configuration options
* @returns {ApiPromise<RetrieveBatchResponse>} A promise that resolves with the batch details
*
* @example
* // First trigger a batch
* const response = await batch.trigger([
* { id: "simple-task", payload: { message: "Hello, World!" } }
* ]);
*
* // Then retrieve the batch details
* const batchDetails = await batch.retrieve(response.batchId);
* console.log("batch", batchDetails);
*/
function retrieveBatch(
batchId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveBatchV2Response> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "batch.retrieve()",
icon: "batch",
attributes: {
batchId: batchId,
...accessoryAttributes({
items: [
{
text: batchId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.retrieveBatch(batchId, $requestOptions);
}
+106
View File
@@ -0,0 +1,106 @@
import { SemanticInternalAttributes } from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
export type CacheMetadata = {
createdTime: number;
ttl?: number | null;
};
export type CacheEntry<Value = unknown> = {
metadata: CacheMetadata;
value: Value;
};
export type Eventually<Value> = Value | null | undefined | Promise<Value | null | undefined>;
export type CacheStore<Value = any> = {
name?: string;
get: (key: string) => Eventually<CacheEntry<Value>>;
set: (key: string, value: CacheEntry<Value>) => unknown | Promise<unknown>;
delete: (key: string) => unknown | Promise<unknown>;
};
export type CacheFunction = <Value>(
cacheKey: string,
fn: () => Promise<Value> | Value
) => Promise<Value> | Value;
export class InMemoryCache<Value = any> {
private _cache: Map<string, CacheEntry<Value>> = new Map();
get(key: string): Eventually<CacheEntry<Value>> {
return this._cache.get(key);
}
set(key: string, value: CacheEntry<Value>): unknown {
this._cache.set(key, value);
return undefined;
}
delete(key: string): unknown {
this._cache.delete(key);
return undefined;
}
}
/**
* Create a cache function that uses the provided store to cache values. Using InMemoryCache is safe because each task run is isolated.
* @param store
* @returns
*/
export function createCache(store: CacheStore): CacheFunction {
return function cache<Value>(
cacheKey: string,
fn: () => Promise<Value> | Value
): Promise<Value> | Value {
return tracer.startActiveSpan("cache", async (span) => {
span.setAttribute("cache.key", cacheKey);
span.setAttribute(SemanticInternalAttributes.STYLE_ICON, "device-sd-card");
const cacheEntry = await store.get(cacheKey);
if (cacheEntry) {
span.updateName(`cache.hit ${cacheKey}`);
return cacheEntry.value;
}
span.updateName(`cache.miss ${cacheKey}`);
const value = await tracer.startActiveSpan(
"cache.getFreshValue",
async (span) => {
return await fn();
},
{
attributes: {
"cache.key": cacheKey,
[SemanticInternalAttributes.STYLE_ICON]: "device-sd-card",
},
}
);
await tracer.startActiveSpan(
"cache.set",
async (span) => {
await store.set(cacheKey, {
value,
metadata: {
createdTime: Date.now(),
},
});
},
{
attributes: {
"cache.key": cacheKey,
[SemanticInternalAttributes.STYLE_ICON]: "device-sd-card",
},
}
);
return value;
});
};
}
+867
View File
@@ -0,0 +1,867 @@
/**
* Server-side API for chatting with Trigger.dev agents.
*
* @example
* ```ts
* import { AgentChat } from "@trigger.dev/sdk/chat";
*
* const chat = new AgentChat<typeof myAgent>({
* agent: "my-agent",
* clientData: { userId: "user_123" },
* });
*
* const stream = await chat.sendMessage("Review PR #1");
* const text = await stream.text();
* await chat.close();
* ```
*/
import type { SessionTriggerConfig, Task } from "@trigger.dev/core/v3";
import type { ModelMessage, UIMessage, UIMessageChunk } from "ai";
// `readUIMessageStream` is a runtime value — via the ESM/CJS shim so the CJS
// build can `require` ESM-only `ai@7` (see ../imports/ai-runtime.ts).
import { readUIMessageStream } from "../imports/ai-runtime.js";
import {
apiClientManager,
controlSubtype,
SSEStreamSubscription,
TRIGGER_CONTROL_SUBTYPE,
} from "@trigger.dev/core/v3";
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
import { slimSubmitMessageForWire } from "./ai-shared.js";
import { sessions } from "./sessions.js";
// ─── Type inference ────────────────────────────────────────────────
/** Extract the client data (metadata) type from a chat agent task. */
export type InferChatClientData<T> =
T extends Task<any, ChatTaskWirePayload<any, infer TMetadata>, any>
? unknown extends TMetadata
? Record<string, unknown>
: TMetadata
: Record<string, unknown>;
/** Extract the UIMessage type from a chat agent task. */
export type InferChatUIMessage<T> =
T extends Task<any, ChatTaskWirePayload<infer TUIMessage, any>, any> ? TUIMessage : UIMessage;
// ─── Types ─────────────────────────────────────────────────────────
/** Persistable session state — store this to resume across requests. */
export type ChatSession = {
/** Last SSE event ID seen on `session.out` — used to resume without replay. */
lastEventId?: string;
};
/**
* Discriminator passed to per-endpoint `baseURL` and `fetch` callbacks on
* `AgentChat`. Same shape as the type on `TriggerChatTransport` — these
* mirror so customers can share a single resolver between the two clients.
*/
export type AgentChatEndpoint = "in" | "out";
export type AgentChatEndpointContext = {
endpoint: AgentChatEndpoint;
chatId: string;
};
export type AgentChatBaseURLResolver = (ctx: AgentChatEndpointContext) => string;
export type AgentChatFetchOverride = (
url: string,
init: RequestInit,
ctx: AgentChatEndpointContext
) => Promise<Response>;
export type AgentChatOptions<TAgent = unknown> = {
/** The agent task ID to trigger. */
agent: string;
/**
* Conversation ID. Used for tagging runs and correlating messages.
* @default crypto.randomUUID()
*/
id?: string;
/** Client data included in every request. Typed from the agent's clientDataSchema. */
clientData?: InferChatClientData<TAgent>;
/**
* Restore a previous session. Pass `lastEventId` from a previous
* request to resume the SSE stream without replaying old chunks.
*/
session?: ChatSession;
/**
* Called when a new run is triggered for this session (initial start).
* Useful for telemetry / dashboard linking. The runId is the
* friendlyId.
*/
onTriggered?: (event: { runId: string; chatId: string }) => void | Promise<void>;
/**
* Called when a turn completes. Persist `lastEventId` for stream
* resumption across requests.
*/
onTurnComplete?: (event: { chatId: string; lastEventId?: string }) => void | Promise<void>;
/** SSE timeout in seconds. @default 120 */
streamTimeoutSeconds?: number;
/**
* Default trigger config used when starting a new session for this
* chat. Folded into `sessions.start({...triggerConfig})` body.
*/
triggerConfig?: SessionTriggerConfig;
/**
* Override the Trigger.dev API base URL for the chat's `.in/append` and
* `.out` SSE endpoints. String form applies to both; pass a function to
* pick per endpoint. Defaults to `apiClientManager.baseURL` (whatever
* `@trigger.dev/sdk` was configured with — typically `TRIGGER_API_URL`
* env var).
*
* Session creation (`POST /api/v1/sessions`) and token mint
* (`POST /api/v1/auth/jwt/claims`) still flow through
* `apiClientManager` — pass equivalent options to
* `chat.createStartSessionAction` if you need those routed too.
*/
baseURL?: string | AgentChatBaseURLResolver;
/**
* Optional per-request fetch override. Receives the resolved URL, the
* RequestInit, and endpoint context. Use this for header injection
* (tracing), proxy routing, or custom retries. Applies to both the
* `.in/append` POSTs and the `.out` SSE GET.
*/
fetch?: AgentChatFetchOverride;
};
// ─── ChatStream ────────────────────────────────────────────────────
/** Parsed tool call from the stream. */
export type ChatToolCall = {
toolName: string;
toolCallId: string;
input: unknown;
};
/** Parsed tool result from the stream. */
export type ChatToolResult = {
toolCallId: string;
output: unknown;
};
/** Accumulated result after a stream completes. */
export type ChatStreamResult = {
text: string;
toolCalls: ChatToolCall[];
toolResults: ChatToolResult[];
};
/**
* A single turn's response stream from an agent.
*
* Pick one consumption mode:
* - `for await (const chunk of stream)` — typed UIMessageChunk iteration
* - `await stream.result()` — accumulated `{ text, toolCalls, toolResults }`
* - `await stream.text()` — just the text
* - `yield* stream.messages()` — sub-agent pattern (yields UIMessage snapshots)
*/
export class ChatStream {
private readonly _consumerStream: ReadableStream<UIMessageChunk>;
private readonly _messageCollector?: Promise<void>;
private resultPromise: Promise<ChatStreamResult> | undefined;
/** @internal Last UIMessage snapshot from the assistant's response. */
private lastAssistantMessage: UIMessage | undefined;
/** @internal Callback to capture the assistant's response message for accumulation. */
private readonly onAssistantMessage?: (message: UIMessage) => void;
constructor(
stream: ReadableStream<UIMessageChunk>,
onAssistantMessage?: (message: UIMessage) => void
) {
this.onAssistantMessage = onAssistantMessage;
if (onAssistantMessage) {
// Tee the stream: one branch for the consumer, one for message collection
const [consumer, collector] = stream.tee();
this._consumerStream = consumer;
this._messageCollector = (async () => {
for await (const msg of readUIMessageStream({ stream: collector })) {
this.lastAssistantMessage = msg;
}
if (this.lastAssistantMessage) {
onAssistantMessage(this.lastAssistantMessage);
}
})();
} else {
this._consumerStream = stream;
}
}
/** The raw ReadableStream for direct use with AI SDK utilities. */
get stream(): ReadableStream<UIMessageChunk> {
return this._consumerStream;
}
async *[Symbol.asyncIterator](): AsyncIterableIterator<UIMessageChunk> {
const reader = this._consumerStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}
}
/**
* Yields accumulated UIMessage snapshots for the sub-agent tool pattern.
*
* @example
* ```ts
* const stream = await chat.sendMessage("Research this topic");
* yield* stream.messages();
* ```
*/
async *messages(): AsyncGenerator<UIMessage, void, unknown> {
for await (const message of readUIMessageStream({ stream: this._consumerStream })) {
this.lastAssistantMessage = message;
yield message;
}
// When the constructor set up `_messageCollector` (because
// `onAssistantMessage` was provided), that collector IIFE owns
// firing the callback. Skipping it here prevents a double-invoke.
if (this.lastAssistantMessage && this.onAssistantMessage && !this._messageCollector) {
this.onAssistantMessage(this.lastAssistantMessage);
}
}
/** Consume the stream and return the accumulated result. */
result(): Promise<ChatStreamResult> {
if (!this.resultPromise) {
this.resultPromise = this.consumeStream();
}
return this.resultPromise;
}
/** Consume the stream and return just the text. */
async text(): Promise<string> {
return (await this.result()).text;
}
private async consumeStream(): Promise<ChatStreamResult> {
let text = "";
const toolCalls: ChatToolCall[] = [];
const toolResults: ChatToolResult[] = [];
for await (const chunk of this) {
if (chunk.type === "text-delta") {
text += chunk.delta;
} else if (chunk.type === "tool-input-available") {
toolCalls.push({
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
input: chunk.input,
});
} else if (chunk.type === "tool-output-available") {
toolResults.push({
toolCallId: chunk.toolCallId,
output: chunk.output,
});
}
}
return { text, toolCalls, toolResults };
}
}
// ─── Internal ──────────────────────────────────────────────────────
type SessionState = {
lastEventId?: string;
skipToTurnComplete?: boolean;
/** True after the session has been started (sessions.start). */
started: boolean;
};
// ─── AgentChat ─────────────────────────────────────────────────────
/**
* A chat conversation with a Trigger.dev agent.
*
* @example
* ```ts
* // Simple usage
* const chat = new AgentChat<typeof myAgent>({ agent: "my-agent" });
* const text = await (await chat.sendMessage("Hello")).text();
* await chat.close();
*
* // Stateless request handler — persist and restore session
* const chat = new AgentChat<typeof myAgent>({
* agent: "my-agent",
* id: chatId,
* session: { lastEventId: savedLastEventId },
* onTriggered: ({ runId }) => db.save(chatId, { runId }),
* onTurnComplete: ({ lastEventId }) => db.update(chatId, { lastEventId }),
* });
* ```
*/
export class AgentChat<TAgent = unknown> {
private readonly taskId: string;
private readonly chatId: string;
private readonly streamTimeoutSeconds: number;
private readonly clientData: Record<string, unknown> | undefined;
private readonly triggerConfigDefault: SessionTriggerConfig | undefined;
private readonly onTriggered: AgentChatOptions["onTriggered"];
private readonly onTurnComplete: AgentChatOptions["onTurnComplete"];
private readonly baseURLResolver: AgentChatBaseURLResolver;
private readonly fetchOverride: AgentChatFetchOverride | undefined;
private state: SessionState;
constructor(options: AgentChatOptions<TAgent>) {
this.taskId = options.agent;
this.chatId = options.id ?? crypto.randomUUID();
this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? 120;
this.clientData = options.clientData as Record<string, unknown> | undefined;
this.triggerConfigDefault = options.triggerConfig;
this.onTriggered = options.onTriggered;
this.onTurnComplete = options.onTurnComplete;
const baseURLOption = options.baseURL;
this.baseURLResolver =
typeof baseURLOption === "function"
? baseURLOption
: () => baseURLOption ?? apiClientManager.baseURL ?? "https://api.trigger.dev";
this.fetchOverride = options.fetch;
// Hydration: a non-empty `session` means the caller knows the
// session already exists (started in a previous request). Mark
// `started` so we don't re-`sessions.start()` on first message.
const hydrated = !!options.session;
this.state = {
lastEventId: options.session?.lastEventId,
started: hydrated,
};
}
/** The conversation ID. */
get id(): string {
return this.chatId;
}
/** Persistable session state — pass back via `options.session` to resume. */
get session(): ChatSession {
return { lastEventId: this.state.lastEventId };
}
/**
* Eagerly start the session — creates the row and triggers the first
* run. The agent's `onPreload` hook fires immediately. Idempotent: a
* second call is a no-op.
*/
async preload(options?: { idleTimeoutInSeconds?: number }): Promise<ChatSession> {
await this.ensureStarted({ idleTimeoutInSeconds: options?.idleTimeoutInSeconds });
return this.session;
}
/**
* Send a text message and get the response stream.
*
* @example
* ```ts
* const stream = await chat.sendMessage("Review PR #1");
* const text = await stream.text();
* ```
*/
async sendMessage(text: string, options?: { abortSignal?: AbortSignal }): Promise<ChatStream> {
const msgId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const message: UIMessage = {
id: msgId,
role: "user",
parts: [{ type: "text", text }],
};
const rawStream = await this.sendRaw([message], { abortSignal: options?.abortSignal });
return new ChatStream(rawStream);
}
/** Send raw UIMessage-like objects. Use `sendMessage()` for simple text. */
async sendRaw(
messages:
| UIMessage[]
| Array<{
id: string;
role: string;
parts?: unknown[];
[key: string]: unknown;
}>,
options?: {
trigger?: "submit-message" | "regenerate-message";
abortSignal?: AbortSignal;
}
): Promise<ReadableStream<UIMessageChunk>> {
const triggerType = options?.trigger ?? "submit-message";
// Make sure the session exists (and a run is alive). The .in/append
// handler on the server probes currentRunId on every call and
// re-triggers if needed — so we don't need to track runId here.
await this.ensureStarted();
// Slim wire — at most ONE message per record. The agent rebuilds prior
// history from its durable S3 snapshot + session.out replay at run
// boot (or `hydrateMessages` if registered).
//
// For `submit-message`, assistant messages carrying resolved tool parts
// (HITL `addToolOutput` answers) are slimmed to just the resolution
// payload — reasoning blobs, text, and tool `input` come from the
// agent's authoritative chain. `regenerate-message` omits `message`.
if (triggerType === "submit-message" && messages.length === 0) {
throw new Error("AgentChat.sendRaw: 'submit-message' trigger requires at least one message");
}
const lastIfSubmit =
triggerType === "submit-message"
? slimSubmitMessageForWire(messages.at(-1) as UIMessage | undefined)
: undefined;
const payload: ChatTaskWirePayload = {
...(lastIfSubmit ? { message: lastIfSubmit } : {}),
chatId: this.chatId,
trigger: triggerType,
metadata: this.clientData,
} as ChatTaskWirePayload;
await this.appendInputChunk(serializeInputChunk({ kind: "message", payload }));
return this.subscribeToSessionStream(options?.abortSignal);
}
/** Send a steering message during an active stream. */
async steer(text: string): Promise<boolean> {
if (!this.state.started) return false;
const payload: ChatTaskWirePayload = {
message: {
id: `steer-${Date.now()}`,
role: "user",
parts: [{ type: "text", text }],
} as unknown as UIMessage,
chatId: this.chatId,
trigger: "submit-message" as const,
metadata: this.clientData,
};
try {
await this.appendInputChunk(serializeInputChunk({ kind: "message", payload }));
return true;
} catch {
return false;
}
}
/** Stop the current generation (agent stays alive for next turn). */
async stop(): Promise<void> {
if (!this.state.started) return;
this.state.skipToTurnComplete = true;
await this.appendInputChunk(serializeInputChunk({ kind: "stop" })).catch(() => {});
}
/**
* Hand over from a `chat.handover` route handler to a parked
* `handover-prepare` agent run. Wakes the run, which seeds its
* accumulators with `partialAssistantMessage` and continues from
* tool execution onward — the model call for step 1 is skipped.
*
* Used internally by `chat.handover`; not part of the customer
* surface.
*/
async sendHandover(args: {
partialAssistantMessage: ModelMessage[];
/**
* UI messageId from the customer's step-1 stream — propagated to
* the agent so its post-handover chunks merge into the same
* assistant message on the browser.
*/
messageId?: string;
/**
* Whether the customer's step 1 is the final response (pure-text
* finish). When true, the agent runs hooks but skips the LLM
* call. When false, the agent runs `streamText` which executes
* pending tool-calls and continues from step 2.
*/
isFinal: boolean;
}): Promise<void> {
await this.appendInputChunk(
serializeInputChunk({
kind: "handover",
partialAssistantMessage: args.partialAssistantMessage,
messageId: args.messageId,
isFinal: args.isFinal,
})
);
}
/**
* Tell a parked `handover-prepare` agent run that the customer's
* first turn finished pure-text (no tool calls) — the run exits
* cleanly without making an LLM call.
*
* Used internally by `chat.handover`; not part of the customer
* surface.
*/
async sendHandoverSkip(): Promise<void> {
await this.appendInputChunk(serializeInputChunk({ kind: "handover-skip" }));
}
/**
* Send a custom action to the agent.
*
* Actions are not turns. They wake the agent, fire `hydrateMessages`
* (if configured) and `onAction` only — no `onTurnStart` /
* `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no
* `run()` invocation.
*
* The action payload is validated against the agent's `actionSchema`
* on the backend. Use `chat.history.*` inside `onAction` to mutate
* state. To produce a model response from the action, return a
* `StreamTextResult` (or `string` / `UIMessage`) from `onAction` —
* the returned stream is auto-piped over this stream. When `onAction`
* returns `void`, the action is side-effect-only and the returned
* stream completes immediately with `trigger:turn-complete`.
*
* @returns A `ChatStream`. For void actions the stream completes
* immediately. For actions that return a model response, the stream
* carries the assistant chunks.
*
* @example
* ```ts
* const stream = await agentChat.sendAction({ type: "undo" });
* for await (const chunk of stream) {
* if (chunk.type === "text-delta") process.stdout.write(chunk.delta);
* }
* ```
*/
async sendAction(action: unknown, options?: { abortSignal?: AbortSignal }): Promise<ChatStream> {
await this.ensureStarted();
const payload: ChatTaskWirePayload = {
chatId: this.chatId,
trigger: "action" as const,
action,
metadata: this.clientData,
};
try {
await this.appendInputChunk(serializeInputChunk({ kind: "message", payload }));
} catch {
throw new Error("Failed to send action. The session may have ended.");
}
const rawStream = this.subscribeToSessionStream(options?.abortSignal);
return new ChatStream(rawStream);
}
/** Close the conversation — agent exits its loop gracefully. */
async close(): Promise<boolean> {
if (!this.state.started) return false;
try {
await this.appendInputChunk(
serializeInputChunk({
kind: "message",
payload: {
chatId: this.chatId,
trigger: "close",
} satisfies ChatTaskWirePayload,
})
);
this.state = { ...this.state, started: false };
return true;
} catch {
return false;
}
}
/** Reconnect to the response stream (e.g. after a disconnect). */
async reconnect(abortSignal?: AbortSignal): Promise<ReadableStream<UIMessageChunk> | null> {
if (!this.state.started) return null;
return this.subscribeToSessionStream(abortSignal, { sendStopOnAbort: false });
}
// ─── Private ───────────────────────────────────────────────────
private resolveBaseURL(endpoint: AgentChatEndpoint): string {
return this.baseURLResolver({ endpoint, chatId: this.chatId }).replace(/\/$/, "");
}
private async doFetch(
ctx: AgentChatEndpointContext,
url: string,
init: RequestInit
): Promise<Response> {
return this.fetchOverride ? this.fetchOverride(url, init, ctx) : fetch(url, init);
}
private async appendInputChunk(body: string): Promise<void> {
const accessToken = apiClientManager.accessToken ?? "";
const ctx: AgentChatEndpointContext = { endpoint: "in", chatId: this.chatId };
const url = `${this.resolveBaseURL("in")}/realtime/v1/sessions/${encodeURIComponent(this.chatId)}/in/append`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"x-trigger-source": "sdk",
// Idempotency key: the server skips appends whose part id it has
// already committed, so a retried POST can't duplicate the record.
"X-Part-Id": crypto.randomUUID(),
};
// Preview-env sessions are branch-scoped, so the realtime in/out calls must
// carry the branch the session was created on. sessions.start sends it via
// the API client; these raw fetches have to set it themselves.
const branch = apiClientManager.branchName;
if (branch) headers["x-trigger-branch"] = branch;
const response = await this.doFetch(ctx, url, { method: "POST", headers, body });
if (!response.ok) {
const text = await response.text().catch(() => "");
// Match the error shape that ApiClient/zodfetch produced before the
// inline-POST refactor so callers inspecting `error.name ===
// "TriggerApiError"` or `error.status` keep working.
const err = new Error(`appendToSessionStream failed: ${response.status} ${text}`) as Error & {
name: string;
status: number;
};
err.name = "TriggerApiError";
err.status = response.status;
throw err;
}
}
/**
* Idempotent: `sessions.start` upserts on `(env, externalId)`. Two
* concurrent AgentChat instances on the same chatId converge to the
* same session.
*/
private async ensureStarted(options?: { idleTimeoutInSeconds?: number }): Promise<void> {
if (this.state.started) return;
const triggerConfig: SessionTriggerConfig = {
basePayload: {
// `trigger: "preload"` mirrors the browser-mediated
// `chat.createStartSessionAction` shape so the agent runtime fires
// `onPreload` (not `onChatStart` with `preloaded: true`). Without
// this, AgentChat's first run skips both preload and start hooks,
// which is where customer apps typically upsert their Chat row.
// Slim wire — preload carries no message body.
trigger: "preload",
...(this.triggerConfigDefault?.basePayload ?? {}),
chatId: this.chatId,
...(this.clientData ? { metadata: this.clientData } : {}),
},
...(this.triggerConfigDefault?.machine ? { machine: this.triggerConfigDefault.machine } : {}),
...(this.triggerConfigDefault?.queue ? { queue: this.triggerConfigDefault.queue } : {}),
...(this.triggerConfigDefault?.tags ? { tags: this.triggerConfigDefault.tags } : {}),
...(this.triggerConfigDefault?.maxAttempts !== undefined
? { maxAttempts: this.triggerConfigDefault.maxAttempts }
: {}),
...(options?.idleTimeoutInSeconds !== undefined ||
this.triggerConfigDefault?.idleTimeoutInSeconds !== undefined
? {
idleTimeoutInSeconds:
options?.idleTimeoutInSeconds ?? this.triggerConfigDefault?.idleTimeoutInSeconds!,
}
: {}),
};
const created = await sessions.start({
type: "chat.agent",
externalId: this.chatId,
taskIdentifier: this.taskId,
triggerConfig,
});
this.state.started = true;
await this.onTriggered?.({
runId: created.runId,
chatId: this.chatId,
});
}
private subscribeToSessionStream(
abortSignal: AbortSignal | undefined,
options?: { sendStopOnAbort?: boolean }
): ReadableStream<UIMessageChunk> {
const state = this.state;
const accessToken = apiClientManager.accessToken ?? "";
const onTurnComplete = this.onTurnComplete;
const chatId = this.chatId;
const sseCtx: AgentChatEndpointContext = { endpoint: "out", chatId };
const fetchOverride = this.fetchOverride;
const sseFetchClient: typeof fetch | undefined = fetchOverride
? (((input, init) => {
if (typeof input === "string") {
return fetchOverride(input, init ?? {}, sseCtx);
}
if (input instanceof URL) {
return fetchOverride(input.toString(), init ?? {}, sseCtx);
}
// Request — preserve its url + intrinsic init, let any provided
// init override on top (matches fetch(Request, init) semantics).
return fetchOverride(
input.url,
{
method: input.method,
headers: input.headers,
signal: input.signal,
...(init ?? {}),
},
sseCtx
);
}) as typeof fetch)
: undefined;
const internalAbort = new AbortController();
const combinedSignal = abortSignal
? AbortSignal.any([abortSignal, internalAbort.signal])
: internalAbort.signal;
if (abortSignal) {
abortSignal.addEventListener(
"abort",
() => {
if (options?.sendStopOnAbort !== false) {
state.skipToTurnComplete = true;
this.appendInputChunk(serializeInputChunk({ kind: "stop" })).catch(() => {});
}
internalAbort.abort();
},
{ once: true }
);
}
const streamUrl = `${this.resolveBaseURL("out")}/realtime/v1/sessions/${encodeURIComponent(chatId)}/out`;
return new ReadableStream<UIMessageChunk>({
start: async (controller) => {
try {
const subscription = new SSEStreamSubscription(streamUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
// Preview-env sessions are branch-scoped (see appendInputChunk).
...(apiClientManager.branchName
? { "x-trigger-branch": apiClientManager.branchName }
: {}),
},
signal: combinedSignal,
timeoutInSeconds: this.streamTimeoutSeconds,
lastEventId: state.lastEventId,
fetchClient: sseFetchClient,
});
const sseStream = await subscription.subscribe();
const reader = sseStream.getReader();
try {
while (true) {
const next = await reader.read();
if (next.done) {
controller.close();
return;
}
if (combinedSignal.aborted) {
internalAbort.abort();
await reader.cancel();
controller.close();
return;
}
const value = next.value;
if (value.id) state.lastEventId = value.id;
// Trigger control records (turn-complete, upgrade-required)
// route by header — see `client-protocol.mdx`. Their bodies
// are empty; everything substantive is on `value.headers`.
//
// Cross-version bridge: an old agent SDK still writing
// turn-complete / upgrade-required as `chunk.type` data
// records would otherwise stall this loop. Fall back to
// the legacy chunk-type form when no header is present
// so the deploy-skew window between an `AgentChat`
// consumer and a not-yet-redeployed agent doesn't hang.
let controlValue = controlSubtype(value.headers);
if (!controlValue && value.chunk && typeof value.chunk === "object") {
const chunk = value.chunk as { type?: unknown };
if (chunk.type === "trigger:turn-complete") {
controlValue = TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE;
} else if (chunk.type === "trigger:upgrade-required") {
controlValue = TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED;
} else if (typeof chunk.type === "string" && chunk.type.startsWith("trigger:")) {
// Future / unknown `trigger:*` legacy control type —
// drop so it doesn't leak as a UIMessageChunk.
continue;
}
}
if (state.skipToTurnComplete) {
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
state.skipToTurnComplete = false;
}
continue;
}
if (controlValue === TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED) {
// Server has already triggered the new run via
// `end-and-continue`; v2's chunks arrive on the same
// S2 stream. Filter the marker for cleanliness and
// keep reading.
continue;
}
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
// Customer's callback may be async (e.g. persisting
// lastEventId to a DB). Wrap so a rejected Promise
// doesn't surface as an unhandled rejection — that
// would crash Node under `--unhandled-rejections=throw`.
Promise.resolve(
onTurnComplete?.({
chatId,
lastEventId: state.lastEventId,
})
).catch(() => {});
internalAbort.abort();
try {
controller.close();
} catch {
// Controller may already be closed
}
return;
}
// Data record — `value.chunk` is the parsed UIMessageChunk
// (the SSE parser does the JSON envelope unwrap). Drop
// empty/malformed payloads defensively.
if (value.chunk == null) continue;
controller.enqueue(value.chunk as UIMessageChunk);
}
} catch (readError) {
reader.releaseLock();
throw readError;
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
try {
controller.close();
} catch {
// Controller may already be closed
}
return;
}
controller.error(error);
}
},
});
}
}
/**
* Serialize a {@link ChatInputChunk} for `POST …/sessions/:session/:io/append`.
* Session channel records are raw JSON strings — the server wraps them
* in `{ data: <body>, id }` for S2 storage and the subscribe side
* parses the string back for consumers.
*/
function serializeInputChunk(chunk: ChatInputChunk): string {
return JSON.stringify(chunk);
}
+473
View File
@@ -0,0 +1,473 @@
"use client";
/**
* @module @trigger.dev/sdk/chat/react
*
* React hooks for AI SDK chat transport integration.
* Use alongside `@trigger.dev/sdk/chat` for a type-safe, ergonomic DX.
*
* @example
* ```tsx
* import { useChat } from "@ai-sdk/react";
* import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
* import type { chat } from "@/trigger/chat";
*
* function Chat() {
* const transport = useTriggerChatTransport<typeof chat>({
* task: "ai-chat",
* accessToken: ({ chatId }) => fetchToken(chatId),
* });
*
* const { messages, sendMessage } = useChat({ transport });
* }
* ```
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { TriggerChatTransport, type TriggerChatTransportOptions } from "./chat.js";
import type { AnyTask, TaskIdentifier } from "@trigger.dev/core/v3";
import {
PENDING_MESSAGE_INJECTED_TYPE,
type InferChatClientData,
type InferChatUIMessage,
} from "./ai-shared.js";
import type { UIMessage, ChatRequestOptions } from "ai";
/**
* Options for `useTriggerChatTransport`, with a type-safe `task` field.
*
* Pass a task type parameter to get compile-time validation of the task ID:
* ```ts
* useTriggerChatTransport<typeof myTask>({ task: "my-task", ... })
* ```
*/
export type UseTriggerChatTransportOptions<TTask extends AnyTask = AnyTask> = Omit<
TriggerChatTransportOptions<InferChatClientData<TTask>>,
"task"
> & {
/** The task ID. Strongly typed when a task type parameter is provided. */
task: TaskIdentifier<TTask>;
};
export type { InferChatUIMessage };
export type { ChatTransportEvent, ChatTransportSendSource } from "./chat.js";
/**
* React hook that creates and memoizes a `TriggerChatTransport` instance.
*
* The transport is created once on first render and reused for the lifetime
* of the component. This avoids the need for `useMemo` and ensures the
* transport's internal session state (run IDs, lastEventId, etc.)
* is preserved across re-renders.
*
* For dynamic access tokens, pass a function — it will be called on each
* request without needing to recreate the transport.
*
* The `onSessionChange` callback is kept in a ref so the transport always
* calls the latest version without needing to be recreated.
*
* @example
* ```tsx
* import { useChat } from "@ai-sdk/react";
* import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
* import type { chat } from "@/trigger/chat";
*
* function Chat() {
* const transport = useTriggerChatTransport<typeof chat>({
* task: "ai-chat",
* accessToken: ({ chatId }) => fetchToken(chatId),
* });
*
* const { messages, sendMessage } = useChat({ transport });
* }
* ```
*/
export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
options: UseTriggerChatTransportOptions<TTask>
): TriggerChatTransport {
const ref = useRef<TriggerChatTransport | null>(null);
if (ref.current === null) {
ref.current = new TriggerChatTransport(options as TriggerChatTransportOptions);
}
// Keep callbacks up to date without recreating the transport.
const { onSessionChange, clientData, onEvent } = options;
useEffect(() => {
ref.current?.setOnSessionChange(onSessionChange);
}, [onSessionChange]);
useEffect(() => {
ref.current?.setOnEvent(onEvent);
}, [onEvent]);
// Keep `clientData` up to date so the transport's per-turn merge and
// `startSession` callback both see the latest value without
// reconstructing the transport.
useEffect(() => {
ref.current?.setClientData(clientData as Record<string, unknown> | undefined);
}, [clientData]);
// Note: dispose() is NOT called in effect cleanup because React strict mode
// runs cleanup+re-setup, but the transport lives in a ref and isn't recreated.
// Calling dispose() would permanently close the BroadcastChannel.
// The coordinator's beforeunload handler handles tab close cleanup instead.
return ref.current;
}
/**
* Sync chat messages across browser tabs.
*
* Requires `multiTab: true` on the transport. Handles:
* - Tracking read-only state (`isReadOnly`) when another tab is active
* - Broadcasting messages from the active tab to other tabs
* - Receiving messages from other tabs and updating local state via `setMessages`
*
* @example
* ```tsx
* const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken });
* const { messages, setMessages } = useChat({ id: chatId, transport });
* const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
*
* <input disabled={isReadOnly} placeholder={isReadOnly ? "Active in another tab" : "Type a message..."} />
* ```
*/
export function useMultiTabChat<T = unknown>(
transport: TriggerChatTransport,
chatId: string,
messages: T[],
setMessages: (messages: T[]) => void
): { isReadOnly: boolean } {
const [isReadOnly, setIsReadOnly] = useState(() => transport.isReadOnly(chatId));
// Track read-only state
useEffect(() => {
const listener = (id: string, readOnly: boolean) => {
if (id === chatId) setIsReadOnly(readOnly);
};
transport.addReadOnlyListener(listener);
setIsReadOnly(transport.isReadOnly(chatId));
return () => transport.removeReadOnlyListener(listener);
}, [transport, chatId]);
// Active tab: broadcast messages to other tabs on change.
// Only broadcast when THIS tab holds the claim (is the current sender).
// Deferred via requestIdleCallback so the structured clone in
// BroadcastChannel.postMessage never blocks rendering during streaming.
const idleRef = useRef<number | ReturnType<typeof setTimeout> | null>(null);
const latestMessagesRef = useRef(messages);
latestMessagesRef.current = messages;
useEffect(() => {
if (!transport.hasClaim(chatId) || messages.length === 0) return;
if (idleRef.current !== null) return; // Already scheduled
const schedule =
typeof requestIdleCallback === "function"
? requestIdleCallback
: (fn: () => void) => setTimeout(fn, 50);
idleRef.current = schedule(() => {
idleRef.current = null;
if (transport.hasClaim(chatId)) {
transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]);
}
});
}, [transport, chatId, messages]);
// Flush final state when claim is released (turn complete)
useEffect(() => {
if (!transport.hasClaim(chatId) && latestMessagesRef.current.length > 0) {
if (idleRef.current !== null) {
const cancel = typeof cancelIdleCallback === "function" ? cancelIdleCallback : clearTimeout;
cancel(idleRef.current as any);
idleRef.current = null;
}
transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]);
}
}, [transport, chatId, isReadOnly]);
// Read-only tab: receive messages from the active tab
useEffect(() => {
const listener = (id: string, msgs: unknown[]) => {
if (id === chatId) {
setMessages(msgs as T[]);
}
};
transport.addMessagesListener(listener);
return () => transport.removeMessagesListener(listener);
}, [transport, chatId, setMessages]);
return { isReadOnly };
}
// ---------------------------------------------------------------------------
// usePendingMessages — manage steering messages during streaming
// ---------------------------------------------------------------------------
/** A pending message tracked by `usePendingMessages`. */
export type PendingMessage = {
id: string;
text: string;
/** How this message is being handled. */
mode: "steering" | "queued";
/** Whether the backend confirmed this message was injected mid-response. */
injected: boolean;
};
/** Options for `usePendingMessages`. */
export type UsePendingMessagesOptions<TUIMessage extends UIMessage = UIMessage> = {
/** The chat transport instance. */
transport: TriggerChatTransport;
/** The chat session ID. */
chatId: string;
/** The current useChat status. */
status: string;
/** The current messages from useChat. */
messages: TUIMessage[];
/** The setMessages function from useChat. */
setMessages: (fn: TUIMessage[] | ((prev: TUIMessage[]) => TUIMessage[])) => void;
/** The sendMessage function from useChat. */
sendMessage: (message: { text: string }, options?: ChatRequestOptions) => void;
/** Metadata to include when sending (e.g. `{ model }` for model selection). */
metadata?: Record<string, unknown>;
};
/** A message embedded in an injection point data part. */
export type InjectedMessage = {
id: string;
text: string;
};
/** Return value of `usePendingMessages`. */
export type UsePendingMessagesReturn = {
/** Current pending messages with their mode and injection status. */
pending: PendingMessage[];
/** Send a steering message during streaming, or a normal message when ready. */
steer: (text: string) => void;
/** Queue a message for the next turn (sent after current response finishes). */
queue: (text: string) => void;
/** Promote a queued message to a steering message (sends via input stream immediately). */
promoteToSteering: (id: string) => void;
/** Check if an assistant message part is an injection point. */
isInjectionPoint: (part: unknown) => boolean;
/** Get the injected message IDs from an injection point part. */
getInjectedMessageIds: (part: unknown) => string[];
/** Get the injected messages (id + text) from an injection point part. Self-contained — works after turn complete. */
getInjectedMessages: (part: unknown) => InjectedMessage[];
};
/**
* React hook for managing pending messages (steering) during streaming.
*
* Handles:
* - Sending messages via input stream during streaming (bypassing useChat)
* - Tracking which messages were injected mid-response vs queued for next turn
* - Inserting injected messages into the conversation on turn complete
* - Auto-sending non-injected messages as the next turn
*
* @example
* ```tsx
* const pending = usePendingMessages({
* transport, chatId, status, messages, setMessages, sendMessage,
* metadata: { model },
* });
*
* // In the form:
* <form onSubmit={(e) => {
* e.preventDefault();
* pending.send(input);
* setInput("");
* }}>
*
* // Render pending messages:
* {pending.pending.map(msg => (
* <div key={msg.id}>{msg.text} — {msg.injected ? "Injected" : "Pending"}</div>
* ))}
*
* // Render injection points inline in assistant messages:
* {msg.parts.map((part, i) =>
* pending.isInjectionPoint(part)
* ? <InjectionMarker key={i} ids={pending.getInjectedMessageIds(part)} />
* : <Part key={i} part={part} />
* )}
* ```
*/
export function usePendingMessages<TUIMessage extends UIMessage = UIMessage>(
options: UsePendingMessagesOptions<TUIMessage>
): UsePendingMessagesReturn {
const {
transport,
chatId,
status,
messages,
setMessages: _setMessages,
sendMessage,
metadata,
} = options;
// Internal state: track messages with their mode
type InternalMessage = TUIMessage & { _mode: "steering" | "queued" };
const [pendingMsgs, setPendingMsgs] = useState<InternalMessage[]>([]);
const pendingMsgsRef = useRef(pendingMsgs);
pendingMsgsRef.current = pendingMsgs;
const injectedIdsRef = useRef<Set<string>>(new Set());
const prevStatusRef = useRef(status);
// Watch for injection confirmation chunks in streaming messages
useEffect(() => {
if (status !== "streaming") return;
let newlyInjected = false;
for (const msg of messages) {
if (msg.role !== "assistant") continue;
for (const part of msg.parts ?? []) {
if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
const messageIds = (part as any).data?.messageIds;
if (Array.isArray(messageIds)) {
for (const id of messageIds) {
if (!injectedIdsRef.current.has(id)) {
injectedIdsRef.current.add(id);
newlyInjected = true;
}
}
}
}
}
}
// Remove injected steering messages from the pending overlay immediately
if (newlyInjected) {
setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
}
}, [status, messages]);
// Handle turn completion
useEffect(() => {
const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
prevStatusRef.current = status;
if (!turnCompleted) return;
// Auto-send non-injected messages as the next turn.
// This includes queued messages AND steering messages that weren't
// injected (arrived too late, no prepareStep boundary, etc.).
// Note: steering messages were also sent via sendPendingMessage to
// the backend's wire buffer, so the backend may already have them.
// Calling sendMessage here ensures useChat subscribes to the response.
const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id));
// Clean up
setPendingMsgs([]);
injectedIdsRef.current.clear();
promotedIdsRef.current.clear();
// Auto-send as next turn
if (toSend.length > 0) {
const text = toSend.map((m) => (m.parts?.[0] as any)?.text ?? "").join("\n");
sendMessage({ text }, metadata ? { metadata } : undefined);
}
}, [status, pendingMsgs, sendMessage, metadata, messages]);
// Send a steering message (injected mid-response via prepareStep)
const steer = useCallback(
(text: string) => {
if (status === "streaming") {
const msg = {
id: crypto.randomUUID(),
role: "user" as const,
parts: [{ type: "text" as const, text }],
_mode: "steering" as const,
} as InternalMessage;
transport.sendPendingMessage(chatId, msg, metadata);
setPendingMsgs((prev) => [...prev, msg]);
} else {
// Not streaming — just send normally
sendMessage({ text }, metadata ? { metadata } : undefined);
}
},
[status, transport, chatId, sendMessage, metadata]
);
// Queue a message for the next turn (no injection attempt)
const queue = useCallback(
(text: string) => {
if (status === "streaming") {
const msg = {
id: crypto.randomUUID(),
role: "user" as const,
parts: [{ type: "text" as const, text }],
_mode: "queued" as const,
} as InternalMessage;
setPendingMsgs((prev) => [...prev, msg]);
} else {
sendMessage({ text }, metadata ? { metadata } : undefined);
}
},
[status, sendMessage, metadata]
);
// Promote a queued message to steering (send via input stream immediately)
const promotedIdsRef = useRef<Set<string>>(new Set());
const promoteToSteering = useCallback(
(id: string) => {
// Guard against double-click — ref check is synchronous
if (promotedIdsRef.current.has(id)) {
return;
}
// Read from the ref, send OUTSIDE the state updater. React may invoke
// updaters more than once (StrictMode, update rebasing), so a network
// call inside one can double-send.
const msg = pendingMsgsRef.current.find((m) => m.id === id);
if (!msg || msg._mode !== "queued") return;
promotedIdsRef.current.add(id);
transport.sendPendingMessage(chatId, msg, metadata);
setPendingMsgs((prev) =>
prev.map((m) => (m.id === id ? { ...m, _mode: "steering" as const } : m))
);
},
[transport, chatId, metadata]
);
const isInjectionPoint = useCallback(
(part: unknown): boolean =>
typeof part === "object" &&
part !== null &&
(part as any).type === PENDING_MESSAGE_INJECTED_TYPE,
[]
);
const getInjectedMessageIds = useCallback(
(part: unknown): string[] => {
if (!isInjectionPoint(part)) return [];
const ids = (part as any).data?.messageIds;
return Array.isArray(ids) ? ids : [];
},
[isInjectionPoint]
);
const getInjectedMessages = useCallback(
(part: unknown): InjectedMessage[] => {
if (!isInjectionPoint(part)) return [];
const msgs = (part as any).data?.messages;
return Array.isArray(msgs) ? msgs : [];
},
[isInjectionPoint]
);
const pending: PendingMessage[] = pendingMsgs.map((m) => ({
id: m.id,
text: (m.parts?.[0] as any)?.text ?? "",
mode: m._mode,
injected: injectedIdsRef.current.has(m.id),
}));
return {
pending,
steer,
queue,
promoteToSteering,
isInjectionPoint,
getInjectedMessageIds,
getInjectedMessages,
};
}
@@ -0,0 +1,870 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { simulateReadableStream, streamText } from "ai";
import type { UIMessageChunk } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
// Stub `SessionStreamInstance` so the handler's S2 tee is a no-op
// instead of trying to reach a real S2 endpoint. The real one calls
// `apiClient.initializeSessionStream` then pipes via S2 — both are
// out of scope for handler-shape tests.
vi.mock("@trigger.dev/core/v3", async (importActual) => {
const actual = (await importActual()) as Record<string, unknown>;
class StubSessionStreamInstance<T> {
constructor(opts: { source: ReadableStream<T> }) {
// Drain the source so the upstream tee doesn't backpressure-stall
// the SSE half. We don't keep the chunks — durability/resume is
// out of scope here.
void (async () => {
const reader = opts.source.getReader();
try {
while (true) {
const { done } = await reader.read();
if (done) break;
}
} finally {
reader.releaseLock();
}
})();
}
async wait() {
return { written: 0 };
}
}
return { ...actual, SessionStreamInstance: StubSessionStreamInstance };
});
// Import AFTER the mock so chat-server picks up the stubbed class.
import { chat } from "./chat-server.js";
import { apiClientManager } from "@trigger.dev/core/v3";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 5, text: 5, reasoning: undefined },
},
},
],
});
}
function toolCallStream(): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "weather",
input: JSON.stringify({ city: "tokyo" }),
},
{
type: "finish",
finishReason: { unified: "tool-calls", raw: "tool-calls" },
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 5, text: 0, reasoning: undefined },
},
},
],
});
}
function makeRequest(body: unknown): Request {
return new Request("https://my-app.example/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const SESSION_PAT = "tr_session_pat_for_handover";
function createSessionResponse(externalId: string): Response {
return new Response(
JSON.stringify({
id: "session_test",
externalId,
type: "chat.agent",
taskIdentifier: "test-agent",
triggerConfig: {
basePayload: { chatId: externalId, trigger: "handover-prepare" },
idleTimeoutInSeconds: 60,
},
currentRunId: "run_test",
runId: "run_test",
publicAccessToken: SESSION_PAT,
tags: [],
metadata: null,
closedAt: null,
closedReason: null,
expiresAt: null,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
isCached: false,
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
}
function appendOkResponse(): Response {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
async function readSSEBodyToChunks(res: Response): Promise<UIMessageChunk[]> {
const text = await res.text();
return text
.split("\n\n")
.filter((b) => b.startsWith("data: "))
.map((b) => JSON.parse(b.slice(6)) as UIMessageChunk);
}
type CapturedRequest = { url: string; init?: RequestInit };
async function withApiContext<T>(fn: () => Promise<T>): Promise<T> {
return apiClientManager.runWithConfig(
{
baseURL: "https://api.test.trigger.dev",
secretKey: "tr_test_secret",
},
fn
);
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.headStart (route handler)", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("creates the session with handover-prepare in basePayload and returns the session PAT in headers", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-1");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const handler = chat.headStart({
agentId: "test-agent",
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi back") }),
}),
});
},
});
const res = await withApiContext(() =>
handler(
makeRequest({
chatId: "chat-1",
trigger: "submit-message",
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
})
)
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Trigger-Chat-Id")).toBe("chat-1");
expect(res.headers.get("X-Trigger-Chat-Access-Token")).toBe(SESSION_PAT);
expect(res.headers.get("Content-Type")).toMatch(/text\/event-stream/);
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
expect(sessionCreate).toBeDefined();
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.type).toBe("chat.agent");
expect(body.externalId).toBe("chat-1");
expect(body.taskIdentifier).toBe("test-agent");
// The trigger payload is rewritten to handover-prepare even though the
// browser sent submit-message — the agent boots into the handover wait branch.
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60);
});
it("merges triggerConfig tags and queue into createSession", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-1");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
return new Response(
new ReadableStream({
start(c) {
c.close();
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
}
);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const handler = chat.headStart({
agentId: "test-agent",
triggerConfig: {
tags: ["org:acme", "agentic-run:xyz"],
queue: "my-queue",
},
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi back") }),
}),
});
},
});
await withApiContext(() =>
handler(
makeRequest({
chatId: "chat-1",
trigger: "submit-message",
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
})
)
);
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
expect(sessionCreate).toBeDefined();
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.triggerConfig.tags).toEqual(["chat:chat-1", "org:acme", "agentic-run:xyz"]);
expect(body.triggerConfig.queue).toBe("my-queue");
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
});
it("dispatches handover with isFinal=true on pure-text finishReason", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-final");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
// Stitched response subscribes to `.out` after handover.
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
return new Response(
new ReadableStream({
start(c) {
c.close();
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
}
);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const handler = chat.headStart({
agentId: "test-agent",
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("just a text reply") }),
}),
});
},
});
const res = await withApiContext(() =>
handler(
makeRequest({
chatId: "chat-final",
trigger: "submit-message",
// Slim wire: head-start ships full history via `headStartMessages`
// (not `messages` / `message`). The route handler reads that field
// off the request body before invoking the customer's run().
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
})
)
);
// Drain the SSE body so handoverWhenDone observes finishReason.
const chunks = await readSSEBodyToChunks(res);
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
// Give the deferred handoverWhenDone a tick to dispatch.
await new Promise((r) => setTimeout(r, 30));
const handoverPost = requests.find(
(r) =>
r.url.includes("/realtime/v1/sessions/chat-final/in/append") && r.init?.body !== undefined
);
expect(handoverPost).toBeDefined();
const body = JSON.parse(handoverPost!.init!.body as string);
// Pure-text finishes go through `kind: "handover"` with `isFinal: true`
// so the agent runs hooks (persistence, etc.) without making an LLM call.
expect(body.kind).toBe("handover");
expect(body.isFinal).toBe(true);
// The partial carries the customer's response messages — a single
// assistant message with the streamed text.
expect(Array.isArray(body.partialAssistantMessage)).toBe(true);
const assistant = body.partialAssistantMessage.find(
(m: { role: string }) => m.role === "assistant"
);
expect(assistant).toBeDefined();
});
it("dispatches handover with response.messages on tool-call finishReason", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-tool");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
// Stitched response now subscribes to `.out` after handover to
// pick up agent-side chunks. Return an empty SSE body that
// closes immediately — this test validates dispatch only, not
// the agent-side resume.
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
return new Response(
new ReadableStream({
start(c) {
c.close();
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
}
);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
// Schema-only tool — no execute. The mock model emits a tool-call;
// AI SDK doesn't run it (no execute) and finishes with "tool-calls".
const { tool } = await import("ai");
const { z } = await import("zod");
const weatherTool = tool({
description: "weather",
inputSchema: z.object({ city: z.string() }),
});
const handler = chat.headStart({
agentId: "test-agent",
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions({ tools: { weather: weatherTool } }),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: toolCallStream() }),
}),
});
},
});
const res = await withApiContext(() =>
handler(
makeRequest({
chatId: "chat-tool",
trigger: "submit-message",
headStartMessages: [
{ id: "m1", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] },
],
})
)
);
await readSSEBodyToChunks(res);
await new Promise((r) => setTimeout(r, 30));
const handoverPost = requests.find(
(r) =>
r.url.includes("/realtime/v1/sessions/chat-tool/in/append") && r.init?.body !== undefined
);
expect(handoverPost).toBeDefined();
const body = JSON.parse(handoverPost!.init!.body as string);
expect(body.kind).toBe("handover");
expect(body.isFinal).toBe(false); // pending tool-calls — agent runs streamText
expect(Array.isArray(body.partialAssistantMessage)).toBe(true);
// The partial is reshaped into AI SDK's tool-approval round so the
// agent's `streamText` can resume by executing the pending tool-call
// before step 2. Assistant gets a `tool-approval-request` part
// alongside the original `tool-call`; a trailing `tool` message
// carries the `tool-approval-response { approved: true }`.
const assistant = body.partialAssistantMessage.find(
(m: { role: string }) => m.role === "assistant"
);
expect(assistant).toBeDefined();
const toolCallPart = assistant.content.find((p: { type: string }) => p.type === "tool-call");
expect(toolCallPart).toBeDefined();
const approvalRequestPart = assistant.content.find(
(p: { type: string }) => p.type === "tool-approval-request"
);
expect(approvalRequestPart).toBeDefined();
expect(approvalRequestPart.toolCallId).toBe(toolCallPart.toolCallId);
const trailingTool = body.partialAssistantMessage[body.partialAssistantMessage.length - 1];
expect(trailingTool.role).toBe("tool");
const approvalResponsePart = trailingTool.content.find(
(p: { type: string }) => p.type === "tool-approval-response"
);
expect(approvalResponsePart).toBeDefined();
expect(approvalResponsePart.approvalId).toBe(approvalRequestPart.approvalId);
expect(approvalResponsePart.approved).toBe(true);
});
it("rejects requests missing chatId", async () => {
global.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 }));
const handler = chat.headStart({
agentId: "test-agent",
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("x") }),
}),
});
},
});
await expect(
withApiContext(() =>
handler(
makeRequest({
// no chatId
trigger: "submit-message",
messages: [],
})
)
)
).rejects.toThrow(/chatId/);
});
});
describe("chat.startHeadStart (detached)", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
function wireFetch(requests: CapturedRequest[]) {
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-1");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
}
const userMessages = [
{ id: "m1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] },
];
it("returns { chatId, completion } (no Response) and creates the session with handover-prepare + headStartMessages", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const result = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi back") }),
}),
}),
})
);
// Shape: a plain object, not a Response.
expect(result.chatId).toBe("chat-1");
expect(typeof result.completion.then).toBe("function");
expect(result).not.toBeInstanceOf(Response);
await result.completion;
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
expect(sessionCreate).toBeDefined();
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.type).toBe("chat.agent");
expect(body.externalId).toBe("chat-1");
expect(body.taskIdentifier).toBe("test-agent");
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
// Full first-turn history rides on headStartMessages (not /in/append).
expect(body.triggerConfig.basePayload.headStartMessages).toHaveLength(1);
expect(body.triggerConfig.basePayload.headStartMessages[0].id).toBe("m1");
});
it("dispatches a final handover (isFinal: true) on a pure-text step 1", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the answer") }),
}),
}),
})
);
await completion;
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
const appendBody = append!.init!.body as string;
expect(appendBody).toContain('"kind":"handover"');
expect(appendBody).toContain('"isFinal":true');
// A stable assistant messageId is carried across the handover boundary.
expect(appendBody).toContain('"messageId":');
});
it("dispatches a non-final handover (isFinal: false) on a tool-call step 1", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: toolCallStream() }),
}),
}),
})
);
await completion;
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
const appendBody = append!.init!.body as string;
expect(appendBody).toContain('"kind":"handover"');
expect(appendBody).toContain('"isFinal":false');
});
it("merges metadata into the handover-prepare run payload (never to the browser)", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
metadata: { userActorToken: "tr_uat_secret", projectRef: "proj_x" },
run: async ({ chat: chatHelper }) =>
streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
}),
}),
})
);
await completion;
const sessionCreate = requests.find(
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.triggerConfig.basePayload.metadata.userActorToken).toBe("tr_uat_secret");
expect(body.triggerConfig.basePayload.metadata.projectRef).toBe("proj_x");
});
it("signals handover-skip and rejects completion when the warm step throws", async () => {
const requests: CapturedRequest[] = [];
wireFetch(requests);
const { completion } = await withApiContext(() =>
chat.startHeadStart({
agentId: "test-agent",
chatId: "chat-1",
messages: userMessages,
run: async () => {
throw new Error("warm step boom");
},
})
);
await expect(completion).rejects.toThrow("warm step boom");
const append = requests.find((r) => r.url.endsWith("/in/append"));
expect(append).toBeDefined();
expect(append!.init!.body as string).toContain('"kind":"handover-skip"');
});
});
describe("chat.toNodeListener", () => {
/**
* Build a fake Node IncomingMessage that yields a JSON body.
* AsyncIterable so the listener can `for await` over it.
*/
function fakeNodeRequest(opts: {
method?: string;
url?: string;
host?: string;
headers?: Record<string, string | string[]>;
body?: string;
}) {
const bodyBytes = opts.body ? new TextEncoder().encode(opts.body) : undefined;
const headers = {
host: opts.host ?? "example.com",
...(opts.body ? { "content-type": "application/json" } : {}),
...(opts.headers ?? {}),
};
const errorListeners: Array<(e: Error) => void> = [];
return {
method: opts.method ?? "POST",
url: opts.url ?? "/api/chat",
headers,
on(event: string, listener: (e: Error) => void) {
if (event === "error") errorListeners.push(listener);
return this;
},
async *[Symbol.asyncIterator]() {
if (bodyBytes) yield bodyBytes;
},
};
}
function fakeNodeResponse() {
const writes: Uint8Array[] = [];
let ended = false;
let endChunk: Uint8Array | string | undefined;
const closeListeners: Array<() => void> = [];
const headers: Record<string, string | number | readonly string[]> = {};
const obj = {
statusCode: 200,
headersSent: false,
setHeader(name: string, value: string | number | readonly string[]) {
headers[name.toLowerCase()] = value;
},
write(chunk: Uint8Array | string) {
if (typeof chunk === "string") {
writes.push(new TextEncoder().encode(chunk));
} else {
writes.push(chunk);
}
obj.headersSent = true;
return true;
},
end(chunk?: Uint8Array | string) {
ended = true;
endChunk = chunk;
},
on(event: string, listener: () => void) {
if (event === "close") closeListeners.push(listener);
return obj;
},
// test helpers
_written() {
const all = [...writes];
if (typeof endChunk === "string") all.push(new TextEncoder().encode(endChunk));
else if (endChunk) all.push(endChunk);
let total = 0;
for (const c of all) total += c.length;
const merged = new Uint8Array(total);
let offset = 0;
for (const c of all) {
merged.set(c, offset);
offset += c.length;
}
return new TextDecoder().decode(merged);
},
_ended: () => ended,
_headers: () => headers,
_close: () => {
for (const l of closeListeners) l();
},
};
return obj;
}
it("converts the Node request into a Web Request, calls the handler, and forwards the response", async () => {
const seen: { method?: string; url?: string; ct?: string | null; body?: string } = {};
const webHandler = async (req: Request): Promise<Response> => {
seen.method = req.method;
seen.url = req.url;
seen.ct = req.headers.get("content-type");
seen.body = await req.text();
return new Response("ok", {
status: 201,
headers: { "x-test": "1", "content-type": "text/plain" },
});
};
const listener = chat.toNodeListener(webHandler);
const req = fakeNodeRequest({ body: '{"hello":"world"}' });
const res = fakeNodeResponse();
await listener(req as any, res as any);
expect(seen.method).toBe("POST");
expect(seen.url).toBe("http://example.com/api/chat");
expect(seen.ct).toBe("application/json");
expect(seen.body).toBe('{"hello":"world"}');
expect(res.statusCode).toBe(201);
expect(res._headers()["x-test"]).toBe("1");
expect(res._written()).toBe("ok");
expect(res._ended()).toBe(true);
});
it("streams the Web Response body to the Node response chunk by chunk (no buffering)", async () => {
const chunkOrder: string[] = [];
const webHandler = async (): Promise<Response> => {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
for (const piece of ["one\n", "two\n", "three\n"]) {
chunkOrder.push("emit-" + piece.trim());
controller.enqueue(encoder.encode(piece));
await new Promise((r) => setTimeout(r, 5));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
};
const listener = chat.toNodeListener(webHandler);
const req = fakeNodeRequest({});
const res = fakeNodeResponse();
await listener(req as any, res as any);
expect(res._written()).toBe("one\ntwo\nthree\n");
expect(chunkOrder).toEqual(["emit-one", "emit-two", "emit-three"]);
expect(res._headers()["content-type"]).toBe("text/event-stream");
});
it("propagates client disconnect to the Web handler via AbortSignal", async () => {
let signal: AbortSignal | undefined;
let aborted = false;
const webHandler = async (req: Request): Promise<Response> => {
signal = req.signal;
signal.addEventListener("abort", () => {
aborted = true;
});
// Return a never-ending stream so the listener stays open until close.
return new Response(
new ReadableStream({
start() {
// never enqueues
},
})
);
};
const listener = chat.toNodeListener(webHandler);
const req = fakeNodeRequest({});
const res = fakeNodeResponse();
// Run listener in background (it'll hang on the never-ending stream).
const pending = listener(req as any, res as any);
// Wait a tick for the handler to attach the abort listener.
await new Promise((r) => setTimeout(r, 5));
res._close();
expect(aborted).toBe(true);
// Cleanup: the listener will throw (abort) and we don't care about the result.
await pending.catch(() => {});
});
it("returns 500 with error text if the handler throws before headers are sent", async () => {
const webHandler = async (): Promise<Response> => {
throw new Error("boom");
};
const listener = chat.toNodeListener(webHandler);
const req = fakeNodeRequest({});
const res = fakeNodeResponse();
await listener(req as any, res as any);
expect(res.statusCode).toBe(500);
expect(res._written()).toBe("boom");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
// Mock BroadcastChannel for testing
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(public name: string) {
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown): void {
if (this.closed) return;
// Deliver to all OTHER instances on the same channel
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed) {
instance.onmessage?.({ data } as MessageEvent);
}
}
}
close(): void {
this.closed = true;
MockBroadcastChannel.instances = MockBroadcastChannel.instances.filter((i) => i !== this);
}
}
describe("ChatTabCoordinator", () => {
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel);
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("tab A claims, tab B sees isReadOnly", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
expect(b.isReadOnly("chat-1")).toBe(false);
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
expect(a.isReadOnly("chat-1")).toBe(false); // Owner is not read-only
a.dispose();
b.dispose();
});
it("tab A releases, tab B sees isReadOnly = false", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
a.release("chat-1");
expect(b.isReadOnly("chat-1")).toBe(false);
a.dispose();
b.dispose();
});
it("fires listener on claim and release", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
a.claim("chat-1");
expect(listener).toHaveBeenCalledWith("chat-1", true);
a.release("chat-1");
expect(listener).toHaveBeenCalledWith("chat-1", false);
a.dispose();
b.dispose();
});
it("removeListener stops notifications", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
b.removeListener(listener);
a.claim("chat-1");
expect(listener).not.toHaveBeenCalled();
a.dispose();
b.dispose();
});
it("claim returns false when another tab holds the chatId", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
expect(a.claim("chat-1")).toBe(true);
expect(b.claim("chat-1")).toBe(false);
a.dispose();
b.dispose();
});
it("supports multiple independent chatIds", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
b.claim("chat-2");
expect(a.isReadOnly("chat-1")).toBe(false);
expect(a.isReadOnly("chat-2")).toBe(true);
expect(b.isReadOnly("chat-1")).toBe(true);
expect(b.isReadOnly("chat-2")).toBe(false);
a.dispose();
b.dispose();
});
it("heartbeat timeout clears stale claim from crashed tab", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
// Simulate tab A crashing (close its channel, stop heartbeats)
a.dispose();
// Advance past heartbeat timeout (10s)
vi.advanceTimersByTime(11_000);
expect(b.isReadOnly("chat-1")).toBe(false);
expect(listener).toHaveBeenCalledWith("chat-1", false);
b.dispose();
});
it("dispose releases all claims", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
a.claim("chat-2");
expect(b.isReadOnly("chat-1")).toBe(true);
expect(b.isReadOnly("chat-2")).toBe(true);
a.dispose();
expect(b.isReadOnly("chat-1")).toBe(false);
expect(b.isReadOnly("chat-2")).toBe(false);
b.dispose();
});
it("gracefully degrades when BroadcastChannel is unavailable", () => {
vi.stubGlobal("BroadcastChannel", undefined);
const coord = new ChatTabCoordinator();
// All operations are no-ops
expect(coord.claim("chat-1")).toBe(true);
expect(coord.isReadOnly("chat-1")).toBe(false);
coord.release("chat-1"); // No error
coord.dispose(); // No error
});
});
@@ -0,0 +1,268 @@
/**
* Coordinates multi-tab access to chat sessions via BroadcastChannel.
*
* When multiple browser tabs open the same chat, only one can be the active
* sender. Others enter read-only mode. The coordinator uses a simple
* claim/release/heartbeat protocol to track ownership per chatId.
*
* Gracefully degrades to a no-op when BroadcastChannel is unavailable
* (SSR, Node.js, old browsers).
*
* @internal
*/
const CHANNEL_NAME = "trigger-chat-tab-coord";
const HEARTBEAT_INTERVAL_MS = 5_000;
const HEARTBEAT_TIMEOUT_MS = 10_000;
type TabMessage =
| { type: "claim"; chatId: string; tabId: string }
| { type: "release"; chatId: string; tabId: string }
| { type: "heartbeat"; chatId: string; tabId: string }
| { type: "messages"; chatId: string; tabId: string; messages: unknown[] }
| { type: "session"; chatId: string; tabId: string; session: { lastEventId?: string } };
type ReadOnlyListener = (chatId: string, isReadOnly: boolean) => void;
type MessagesListener = (chatId: string, messages: unknown[]) => void;
type SessionListener = (chatId: string, session: { lastEventId?: string }) => void;
export class ChatTabCoordinator {
private tabId: string;
private channel: BroadcastChannel | null = null;
/** Claims held by OTHER tabs: chatId -> { tabId, lastSeen } */
private claims = new Map<string, { tabId: string; lastSeen: number }>();
/** chatIds that THIS tab has claimed */
private myClaims = new Set<string>();
private listeners = new Set<ReadOnlyListener>();
private messagesListeners = new Set<MessagesListener>();
private sessionListeners = new Set<SessionListener>();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private beforeUnloadHandler: (() => void) | null = null;
constructor() {
this.tabId =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (typeof BroadcastChannel === "undefined") {
return; // No-op mode
}
this.channel = new BroadcastChannel(CHANNEL_NAME);
this.channel.onmessage = (event: MessageEvent<TabMessage>) => {
this.handleMessage(event.data);
};
// Heartbeat: send for our claims + check for stale claims from other tabs
this.heartbeatTimer = setInterval(() => {
this.sendHeartbeats();
this.expireStaleClaimsFromOtherTabs();
}, HEARTBEAT_INTERVAL_MS);
// Best-effort release on tab close
this.beforeUnloadHandler = () => this.releaseAll();
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", this.beforeUnloadHandler);
}
}
/**
* Attempt to claim a chatId for sending.
* Returns false if another tab already holds it.
*/
claim(chatId: string): boolean {
if (!this.channel) return true; // No-op mode
const existing = this.claims.get(chatId);
if (existing && existing.tabId !== this.tabId) {
return false; // Another tab holds this chat
}
this.myClaims.add(chatId);
this.broadcast({ type: "claim", chatId, tabId: this.tabId });
return true;
}
/** Release a chatId so other tabs can claim it. */
release(chatId: string): void {
if (!this.channel) return;
if (!this.myClaims.has(chatId)) return;
this.myClaims.delete(chatId);
this.broadcast({ type: "release", chatId, tabId: this.tabId });
}
/** Check if THIS tab currently holds a claim for the chatId. */
hasClaim(chatId: string): boolean {
return this.myClaims.has(chatId);
}
/** Check if another tab holds this chatId. */
isReadOnly(chatId: string): boolean {
if (!this.channel) return false;
const claim = this.claims.get(chatId);
return claim != null && claim.tabId !== this.tabId;
}
addListener(fn: ReadOnlyListener): void {
this.listeners.add(fn);
}
removeListener(fn: ReadOnlyListener): void {
this.listeners.delete(fn);
}
/** Broadcast the current messages to other tabs (for real-time sync). */
broadcastMessages(chatId: string, messages: unknown[]): void {
if (!this.channel) return;
this.broadcast({ type: "messages", chatId, tabId: this.tabId, messages });
}
addMessagesListener(fn: MessagesListener): void {
this.messagesListeners.add(fn);
}
removeMessagesListener(fn: MessagesListener): void {
this.messagesListeners.delete(fn);
}
/** Broadcast session state (lastEventId) to other tabs. */
broadcastSession(chatId: string, session: { lastEventId?: string }): void {
if (!this.channel) return;
this.broadcast({ type: "session", chatId, tabId: this.tabId, session });
}
addSessionListener(fn: SessionListener): void {
this.sessionListeners.add(fn);
}
removeSessionListener(fn: SessionListener): void {
this.sessionListeners.delete(fn);
}
/** Clean up channel, timers, and event listeners. */
dispose(): void {
this.releaseAll();
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.beforeUnloadHandler && typeof window !== "undefined") {
window.removeEventListener("beforeunload", this.beforeUnloadHandler);
this.beforeUnloadHandler = null;
}
if (this.channel) {
this.channel.close();
this.channel = null;
}
this.listeners.clear();
this.messagesListeners.clear();
this.sessionListeners.clear();
}
// --- Private ---
private handleMessage(msg: TabMessage): void {
if (msg.tabId === this.tabId) return; // Ignore own messages
switch (msg.type) {
case "claim": {
const wasReadOnly = this.isReadOnly(msg.chatId);
this.claims.set(msg.chatId, { tabId: msg.tabId, lastSeen: Date.now() });
if (!wasReadOnly) {
this.notify(msg.chatId, true);
}
break;
}
case "release": {
const claim = this.claims.get(msg.chatId);
if (claim && claim.tabId === msg.tabId) {
this.claims.delete(msg.chatId);
this.notify(msg.chatId, false);
}
break;
}
case "heartbeat": {
const claim = this.claims.get(msg.chatId);
if (claim && claim.tabId === msg.tabId) {
claim.lastSeen = Date.now();
}
break;
}
case "messages": {
this.notifyMessages(msg.chatId, msg.messages);
break;
}
case "session": {
this.notifySession(msg.chatId, msg.session);
break;
}
}
}
private sendHeartbeats(): void {
for (const chatId of this.myClaims) {
this.broadcast({ type: "heartbeat", chatId, tabId: this.tabId });
}
}
private expireStaleClaimsFromOtherTabs(): void {
const now = Date.now();
for (const [chatId, claim] of this.claims) {
if (claim.tabId !== this.tabId && now - claim.lastSeen > HEARTBEAT_TIMEOUT_MS) {
this.claims.delete(chatId);
this.notify(chatId, false);
}
}
}
private releaseAll(): void {
for (const chatId of [...this.myClaims]) {
this.release(chatId);
}
}
private broadcast(msg: TabMessage): void {
try {
this.channel?.postMessage(msg);
} catch {
// Channel may be closed
}
}
private notify(chatId: string, isReadOnly: boolean): void {
for (const fn of this.listeners) {
try {
fn(chatId, isReadOnly);
} catch {
// Non-fatal
}
}
}
private notifyMessages(chatId: string, messages: unknown[]): void {
for (const fn of this.messagesListeners) {
try {
fn(chatId, messages);
} catch {
// Non-fatal
}
}
}
private notifySession(chatId: string, session: { lastEventId?: string }): void {
for (const fn of this.sessionListeners) {
try {
fn(chatId, session);
} catch {
// Non-fatal
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import type { TriggerConfig } from "@trigger.dev/core/v3";
export type {
HandleErrorArgs,
HandleErrorFunction,
ResolveEnvironmentVariablesFunction,
ResolveEnvironmentVariablesParams,
ResolveEnvironmentVariablesResult,
} from "@trigger.dev/core/v3";
export function defineConfig(config: TriggerConfig): TriggerConfig {
return config;
}
export type { TriggerConfig };
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import type { CreateSessionRequestBody, CreatedSessionResponseBody } from "@trigger.dev/core/v3";
import { chat } from "./ai.js";
import {
__setSessionStartImplForTests,
__setSessionOpenImplForTests,
SessionHandle,
} from "./sessions.js";
import { apiClientManager } from "@trigger.dev/core/v3";
// `auth.createPublicToken` is called by the action when no start token is
// supplied. Provide a minimal API client config so the mint path doesn't
// throw before we get to assert the captured request body.
apiClientManager.setGlobalAPIClientConfiguration({
baseURL: "https://example.invalid",
accessToken: "tr_test_secret",
});
// Capture the request body the action would send to `sessions.start()`.
let lastStartBody: CreateSessionRequestBody | undefined;
function installStartFixture() {
__setSessionStartImplForTests(async (body): Promise<CreatedSessionResponseBody> => {
lastStartBody = body;
return {
id: "session_fixture",
externalId: body.externalId ?? null,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: body.triggerConfig,
currentRunId: "run_fixture",
tags: body.triggerConfig.tags ?? [],
metadata: body.metadata ?? null,
closedAt: null,
closedReason: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
runId: "run_fixture",
publicAccessToken: "tr_pat_fixture",
isCached: false,
};
});
__setSessionOpenImplForTests(() => new SessionHandle("session_fixture"));
}
afterEach(() => {
__setSessionStartImplForTests(undefined);
__setSessionOpenImplForTests(undefined);
lastStartBody = undefined;
});
// Build a fake chat agent task shape that the generic can narrow against.
// We only need the static type — the runtime never invokes this task because
// `__setSessionStartImplForTests` intercepts the network call.
const fakeChat = chat
.withClientData({
schema: z.object({
userId: z.string(),
plan: z.enum(["free", "pro"]),
}),
})
.agent({
id: "fake-chat",
run: async () => undefined as any,
});
describe("chat.createStartSessionAction — runtime", () => {
it("folds typed clientData into basePayload.metadata so onChatStart sees it on the first turn", async () => {
installStartFixture();
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
const result = await start({
chatId: "chat-1",
clientData: { userId: "u-1", plan: "pro" },
});
expect(result.publicAccessToken).toBe("tr_pat_fixture");
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
messages: [],
trigger: "preload",
metadata: { userId: "u-1", plan: "pro" },
chatId: "chat-1",
});
});
it("leaves basePayload.metadata unset when clientData is not provided", async () => {
installStartFixture();
const start = chat.createStartSessionAction("fake-chat");
await start({ chatId: "chat-2" });
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
});
it("prepends chat:{chatId} to triggerConfig.tags and caps at 5", async () => {
installStartFixture();
const start = chat.createStartSessionAction("fake-chat", {
triggerConfig: {
tags: ["org:acme", "a", "b", "c", "d", "e"],
},
});
await start({ chatId: "chat-tags" });
expect(lastStartBody?.triggerConfig.tags).toEqual([
"chat:chat-tags",
"org:acme",
"a",
"b",
"c",
]);
});
it("forwards maxDuration, region, and lockToVersion from triggerConfig", async () => {
installStartFixture();
const start = chat.createStartSessionAction("fake-chat", {
triggerConfig: {
maxDuration: 120,
region: "us-east-1",
lockToVersion: "20260101.1",
},
});
await start({ chatId: "chat-parity" });
expect(lastStartBody?.triggerConfig.maxDuration).toBe(120);
expect(lastStartBody?.triggerConfig.region).toBe("us-east-1");
expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1");
});
it("keeps session-level metadata distinct from per-turn clientData", async () => {
installStartFixture();
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
await start({
chatId: "chat-3",
clientData: { userId: "u-3", plan: "free" },
metadata: { source: "marketing-site" },
});
// Per-turn shape (visible to onPreload / onChatStart):
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
metadata: { userId: "u-3", plan: "free" },
});
// Session-row metadata (opaque, never typed via clientDataSchema):
expect(lastStartBody?.metadata).toEqual({ source: "marketing-site" });
});
});
describe("chat.createStartSessionAction — types", () => {
it("narrows clientData against the chat agent's clientDataSchema", () => {
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
// The clientData field is typed off the agent's schema.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<
{ userId: string; plan: "free" | "pro" } | undefined
>();
// The agent's typed clientData is strictly narrower than `unknown`.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().not.toEqualTypeOf<unknown>();
});
it("defaults clientData to unknown when called without a generic", () => {
const start = chat.createStartSessionAction("fake-chat");
expectTypeOf(start).parameter(0).toHaveProperty("clientData");
// Untyped variant — clientData is `unknown`.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<unknown>();
});
});
@@ -0,0 +1,52 @@
import type {
ApiDeploymentListOptions,
ApiDeploymentListResponseItem,
ApiRequestOptions,
CursorPagePromise,
RetrieveCurrentDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { apiClientManager, isRequestOptions } from "@trigger.dev/core/v3";
export type { ApiDeploymentListResponseItem, RetrieveCurrentDeploymentResponseBody };
export const deployments = {
retrieveCurrent: retrieveCurrentDeployment,
list: listDeployments,
};
/**
* Retrieve the currently promoted deployment for this environment.
*
* Use inside a task to check whether a newer version has been deployed:
*
* ```ts
* import { deployments } from "@trigger.dev/sdk";
*
* const current = await deployments.retrieveCurrent();
* if (current.version !== ctx.run.version) {
* // A newer version is promoted
* }
* ```
*/
function retrieveCurrentDeployment(
requestOptions?: ApiRequestOptions
): Promise<RetrieveCurrentDeploymentResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.retrieveCurrentDeployment(requestOptions);
}
/**
* List deployments for the current environment.
*/
function listDeployments(
options?: ApiDeploymentListOptions,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof ApiDeploymentListResponseItem> {
const apiClient = apiClientManager.clientOrThrow();
if (isRequestOptions(options)) {
return apiClient.listDeployments(undefined, options);
}
return apiClient.listDeployments(options, requestOptions);
}
+374
View File
@@ -0,0 +1,374 @@
import type {
ApiPromise,
ApiRequestOptions,
CreateEnvironmentVariableParams,
EnvironmentVariableResponseBody,
EnvironmentVariableWithSecret,
ImportEnvironmentVariablesParams,
UpdateEnvironmentVariableParams,
} from "@trigger.dev/core/v3";
import {
apiClientManager,
isRequestOptions,
mergeRequestOptions,
taskContext,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
export type { CreateEnvironmentVariableParams, ImportEnvironmentVariablesParams };
export function upload(
projectRef: string,
slug: string,
params: ImportEnvironmentVariablesParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function upload(
params: ImportEnvironmentVariablesParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function upload(
projectRefOrParams: string | ImportEnvironmentVariablesParams,
slugOrRequestOptions?: string | ApiRequestOptions,
params?: ImportEnvironmentVariablesParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody> {
let $projectRef: string;
let $params: ImportEnvironmentVariablesParams;
let $slug: string;
const $requestOptions = overloadRequestOptions("upload", slugOrRequestOptions, requestOptions);
if (taskContext.ctx) {
if (typeof projectRefOrParams === "string") {
$projectRef = projectRefOrParams;
$slug =
typeof slugOrRequestOptions === "string"
? slugOrRequestOptions
: taskContext.ctx.environment.slug;
if (!params) {
throw new Error("params is required");
}
$params = params;
} else {
$params = projectRefOrParams;
$projectRef = taskContext.ctx.project.ref;
$slug = taskContext.ctx.environment.slug;
}
} else {
if (typeof projectRefOrParams !== "string") {
throw new Error("projectRef is required");
}
if (!slugOrRequestOptions || typeof slugOrRequestOptions !== "string") {
throw new Error("slug is required");
}
if (!params) {
throw new Error("params is required");
}
$projectRef = projectRefOrParams;
$slug = slugOrRequestOptions;
$params = params;
}
const apiClient = apiClientManager.clientOrThrow();
return apiClient.importEnvVars($projectRef, $slug, $params, $requestOptions);
}
export function list(
projectRef: string,
slug: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret[]>;
export function list(
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret[]>;
export function list(
projectRefOrRequestOptions?: string | ApiRequestOptions,
slug?: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret[]> {
const $projectRef = !isRequestOptions(projectRefOrRequestOptions)
? projectRefOrRequestOptions
: taskContext.ctx?.project.ref;
const $slug = slug ?? taskContext.ctx?.environment.slug;
let $requestOptions = isRequestOptions(projectRefOrRequestOptions)
? projectRefOrRequestOptions
: requestOptions;
if (!$projectRef) {
throw new Error("projectRef is required");
}
if (!$slug) {
throw new Error("slug is required");
}
$requestOptions = mergeRequestOptions(
{
tracer,
name: "envvars.list()",
icon: "id-badge",
},
$requestOptions
);
const apiClient = apiClientManager.clientOrThrow();
return apiClient.listEnvVars($projectRef, $slug, $requestOptions);
}
export function create(
projectRef: string,
slug: string,
params: CreateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function create(
params: CreateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function create(
projectRefOrParams: string | CreateEnvironmentVariableParams,
slugOrRequestOptions?: string | ApiRequestOptions,
params?: CreateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody> {
let $projectRef: string;
let $slug: string;
let $params: CreateEnvironmentVariableParams;
const $requestOptions = overloadRequestOptions("create", slugOrRequestOptions, requestOptions);
if (taskContext.ctx) {
if (typeof projectRefOrParams === "string") {
$projectRef = projectRefOrParams;
$slug =
typeof slugOrRequestOptions === "string"
? slugOrRequestOptions
: taskContext.ctx.environment.slug;
if (!params) {
throw new Error("params is required");
}
$params = params;
} else {
$params = projectRefOrParams;
$projectRef = taskContext.ctx.project.ref;
$slug = taskContext.ctx.environment.slug;
}
} else {
if (typeof projectRefOrParams !== "string") {
throw new Error("projectRef is required");
}
if (!slugOrRequestOptions || typeof slugOrRequestOptions !== "string") {
throw new Error("slug is required");
}
if (!params) {
throw new Error("params is required");
}
$projectRef = projectRefOrParams;
$slug = slugOrRequestOptions;
$params = params;
}
const apiClient = apiClientManager.clientOrThrow();
return apiClient.createEnvVar($projectRef, $slug, $params, $requestOptions);
}
export function retrieve(
projectRef: string,
slug: string,
name: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret>;
export function retrieve(
name: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret>;
export function retrieve(
projectRefOrName: string,
slugOrRequestOptions?: string | ApiRequestOptions,
name?: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableWithSecret> {
let $projectRef: string;
let $slug: string;
let $name: string;
const $requestOptions = overloadRequestOptions("retrieve", slugOrRequestOptions, requestOptions);
if (typeof name === "string") {
$projectRef = projectRefOrName;
$slug =
typeof slugOrRequestOptions === "string"
? slugOrRequestOptions
: taskContext.ctx?.environment.slug!;
$name = name;
} else {
$projectRef = taskContext.ctx?.project.ref!;
$slug = taskContext.ctx?.environment.slug!;
$name = projectRefOrName;
}
if (!$projectRef) {
throw new Error("projectRef is required");
}
if (!$slug) {
throw new Error("slug is required");
}
const apiClient = apiClientManager.clientOrThrow();
return apiClient.retrieveEnvVar($projectRef, $slug, $name, $requestOptions);
}
export function del(
projectRef: string,
slug: string,
name: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function del(
name: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function del(
projectRefOrName: string,
slugOrRequestOptions?: string | ApiRequestOptions,
name?: string,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody> {
let $projectRef: string;
let $slug: string;
let $name: string;
const $requestOptions = overloadRequestOptions("del", slugOrRequestOptions, requestOptions);
if (typeof name === "string") {
$projectRef = projectRefOrName;
$slug =
typeof slugOrRequestOptions === "string"
? slugOrRequestOptions
: taskContext.ctx?.environment.slug!;
$name = name;
} else {
$projectRef = taskContext.ctx?.project.ref!;
$slug = taskContext.ctx?.environment.slug!;
$name = projectRefOrName;
}
if (!$projectRef) {
throw new Error("projectRef is required");
}
if (!$slug) {
throw new Error("slug is required");
}
const apiClient = apiClientManager.clientOrThrow();
return apiClient.deleteEnvVar($projectRef, $slug, $name, $requestOptions);
}
export function update(
projectRef: string,
slug: string,
name: string,
params: UpdateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function update(
name: string,
params: UpdateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody>;
export function update(
projectRefOrName: string,
slugOrParams: string | UpdateEnvironmentVariableParams,
nameOrRequestOptions?: string | ApiRequestOptions,
params?: UpdateEnvironmentVariableParams,
requestOptions?: ApiRequestOptions
): ApiPromise<EnvironmentVariableResponseBody> {
let $projectRef: string;
let $slug: string;
let $name: string;
let $params: UpdateEnvironmentVariableParams;
const $requestOptions = overloadRequestOptions("update", nameOrRequestOptions, requestOptions);
if (taskContext.ctx) {
if (typeof slugOrParams === "string") {
$projectRef = slugOrParams;
$slug = slugOrParams ?? taskContext.ctx.environment.slug;
$name =
typeof nameOrRequestOptions === "string"
? nameOrRequestOptions
: taskContext.ctx.environment.slug;
if (!params) {
throw new Error("params is required");
}
$params = params;
} else {
$params = slugOrParams;
$projectRef = taskContext.ctx.project.ref;
$slug = taskContext.ctx.environment.slug;
$name = projectRefOrName;
}
} else {
if (typeof slugOrParams !== "string") {
throw new Error("slug is required");
}
if (!projectRefOrName) {
throw new Error("projectRef is required");
}
if (!params) {
throw new Error("params is required");
}
$projectRef = projectRefOrName;
$slug = slugOrParams;
$name = name!;
$params = params;
}
const apiClient = apiClientManager.clientOrThrow();
return apiClient.updateEnvVar($projectRef, $slug, $name, $params, $requestOptions);
}
function overloadRequestOptions(
name: string,
slugOrRequestOptions?: string | ApiRequestOptions,
requestOptions?: ApiRequestOptions
): ApiRequestOptions {
if (isRequestOptions(slugOrRequestOptions)) {
return mergeRequestOptions(
{
tracer,
name: `envvars.${name}()`,
icon: "id-badge",
},
slugOrRequestOptions
);
} else {
return mergeRequestOptions(
{
tracer,
name: `envvars.${name}()`,
icon: "id-badge",
},
requestOptions
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { heartbeats as coreHeartbeats } from "@trigger.dev/core/v3";
/**
*
* Yields to the Trigger.dev runtime to keep the task alive.
*
* This is a cooperative "heartbeat" that you can call as often as you like
* inside long-running or CPU-heavy loops (e.g. parsing large files, processing
* many records, or handling big Textract results).
*
* You dont need to worry about over-calling it: the underlying implementation
* automatically decides when to actually yield to the event loop and send a
* heartbeat to the Trigger.dev runtime. Extra calls are effectively free.
*
* ### Example
* ```ts
* import { heartbeats } from "@trigger.dev/sdk/v3";
*
* for (const row of bigDataset) {
* process(row);
* await heartbeats.yield(); // safe to call every iteration
* }
* ```
*
* Using this regularly prevents `TASK_RUN_STALLED_EXECUTING` errors by ensuring
* the run never appears idle, even during heavy synchronous work.
*
* This function is also safe to call from outside of a Trigger.dev task run, it will effectively be a no-op.
*/
async function heartbeatsYield() {
await coreHeartbeats.yield();
}
/**
* Returns the last heartbeat timestamp, for debugging purposes only. You probably don't need this.
*/
function heartbeatsGetLastHeartbeat() {
return coreHeartbeats.lastHeartbeat;
}
export const heartbeats = {
yield: heartbeatsYield,
getLastHeartbeat: heartbeatsGetLastHeartbeat,
};
+161
View File
@@ -0,0 +1,161 @@
import {
lifecycleHooks,
type AnyOnStartHookFunction,
type TaskStartHookParams,
type OnStartHookFunction,
type AnyOnFailureHookFunction,
type AnyOnSuccessHookFunction,
type AnyOnCompleteHookFunction,
type TaskCompleteResult,
type AnyOnWaitHookFunction,
type AnyOnResumeHookFunction,
type AnyOnCatchErrorHookFunction,
type AnyOnMiddlewareHookFunction,
type AnyOnCancelHookFunction,
type AnyOnStartAttemptHookFunction,
} from "@trigger.dev/core/v3";
export type {
AnyOnStartHookFunction,
AnyOnStartAttemptHookFunction,
TaskStartHookParams,
OnStartHookFunction,
AnyOnFailureHookFunction,
AnyOnSuccessHookFunction,
AnyOnCompleteHookFunction,
TaskCompleteResult,
AnyOnWaitHookFunction,
AnyOnResumeHookFunction,
AnyOnCatchErrorHookFunction,
AnyOnMiddlewareHookFunction,
AnyOnCancelHookFunction,
};
export function onStart(name: string, fn: AnyOnStartHookFunction): void;
export function onStart(fn: AnyOnStartHookFunction): void;
export function onStart(
fnOrName: string | AnyOnStartHookFunction,
fn?: AnyOnStartHookFunction
): void {
lifecycleHooks.registerGlobalStartHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onStartAttempt(name: string, fn: AnyOnStartAttemptHookFunction): void;
export function onStartAttempt(fn: AnyOnStartAttemptHookFunction): void;
export function onStartAttempt(
fnOrName: string | AnyOnStartAttemptHookFunction,
fn?: AnyOnStartAttemptHookFunction
): void {
lifecycleHooks.registerGlobalStartAttemptHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onFailure(name: string, fn: AnyOnFailureHookFunction): void;
export function onFailure(fn: AnyOnFailureHookFunction): void;
export function onFailure(
fnOrName: string | AnyOnFailureHookFunction,
fn?: AnyOnFailureHookFunction
): void {
lifecycleHooks.registerGlobalFailureHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onSuccess(name: string, fn: AnyOnSuccessHookFunction): void;
export function onSuccess(fn: AnyOnSuccessHookFunction): void;
export function onSuccess(
fnOrName: string | AnyOnSuccessHookFunction,
fn?: AnyOnSuccessHookFunction
): void {
lifecycleHooks.registerGlobalSuccessHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onComplete(name: string, fn: AnyOnCompleteHookFunction): void;
export function onComplete(fn: AnyOnCompleteHookFunction): void;
export function onComplete(
fnOrName: string | AnyOnCompleteHookFunction,
fn?: AnyOnCompleteHookFunction
): void {
lifecycleHooks.registerGlobalCompleteHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onWait(name: string, fn: AnyOnWaitHookFunction): void;
export function onWait(fn: AnyOnWaitHookFunction): void;
export function onWait(fnOrName: string | AnyOnWaitHookFunction, fn?: AnyOnWaitHookFunction): void {
lifecycleHooks.registerGlobalWaitHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onResume(name: string, fn: AnyOnResumeHookFunction): void;
export function onResume(fn: AnyOnResumeHookFunction): void;
export function onResume(
fnOrName: string | AnyOnResumeHookFunction,
fn?: AnyOnResumeHookFunction
): void {
lifecycleHooks.registerGlobalResumeHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
/** @deprecated Use onCatchError instead */
export function onHandleError(name: string, fn: AnyOnCatchErrorHookFunction): void;
/** @deprecated Use onCatchError instead */
export function onHandleError(fn: AnyOnCatchErrorHookFunction): void;
/** @deprecated Use onCatchError instead */
export function onHandleError(
fnOrName: string | AnyOnCatchErrorHookFunction,
fn?: AnyOnCatchErrorHookFunction
): void {
onCatchError(fnOrName as any, fn as any);
}
export function onCatchError(name: string, fn: AnyOnCatchErrorHookFunction): void;
export function onCatchError(fn: AnyOnCatchErrorHookFunction): void;
export function onCatchError(
fnOrName: string | AnyOnCatchErrorHookFunction,
fn?: AnyOnCatchErrorHookFunction
): void {
lifecycleHooks.registerGlobalCatchErrorHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function middleware(name: string, fn: AnyOnMiddlewareHookFunction): void;
export function middleware(fn: AnyOnMiddlewareHookFunction): void;
export function middleware(
fnOrName: string | AnyOnMiddlewareHookFunction,
fn?: AnyOnMiddlewareHookFunction
): void {
lifecycleHooks.registerGlobalMiddlewareHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onCancel(name: string, fn: AnyOnCancelHookFunction): void;
export function onCancel(fn: AnyOnCancelHookFunction): void;
export function onCancel(
fnOrName: string | AnyOnCancelHookFunction,
fn?: AnyOnCancelHookFunction
): void {
lifecycleHooks.registerGlobalCancelHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
@@ -0,0 +1,12 @@
import {
createIdempotencyKey,
resetIdempotencyKey,
type IdempotencyKey,
} from "@trigger.dev/core/v3";
export const idempotencyKeys = {
create: createIdempotencyKey,
reset: resetIdempotencyKey,
};
export type { IdempotencyKey };
@@ -0,0 +1,2 @@
export { runs, type RunShape, type AnyRunShape } from "./runs.js";
export { configure, auth } from "./auth.js";
+73
View File
@@ -0,0 +1,73 @@
export * from "./cache.js";
export * from "./config.js";
export { retry, type RetryOptions } from "./retry.js";
export { queue, BatchTriggerError } from "./shared.js";
export * from "./tasks.js";
export * from "./batch.js";
export * from "./wait.js";
export * from "./waitUntil.js";
export * from "./usage.js";
export * from "./idempotencyKeys.js";
export * from "./tags.js";
export * from "./metadata.js";
export * from "./timeout.js";
export * from "./webhooks.js";
export * from "./locals.js";
export * from "./otel.js";
export * from "./schemas.js";
export * from "./heartbeats.js";
export * from "./streams.js";
export * from "./sessions.js";
export * from "./query.js";
export type { Context };
import type { Context } from "./shared.js";
import type { ApiClientConfiguration, TaskRunContext } from "@trigger.dev/core/v3";
export type { ApiClientConfiguration, TaskRunContext };
export {
ApiError,
AuthenticationError,
BadRequestError,
ConflictError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
UnprocessableEntityError,
AbortTaskRunError,
OutOfMemoryError,
CompleteTaskWithOutput,
ChatChunkTooLargeError,
isChatChunkTooLargeError,
logger,
type LogLevel,
} from "@trigger.dev/core/v3";
export {
runs,
type RunShape,
type AnyRunShape,
type TaskRunShape,
type RealtimeRun,
type AnyRealtimeRun,
type RetrieveRunResult,
type AnyRetrieveRunResult,
type BulkAction,
} from "./runs.js";
export * as schedules from "./schedules/index.js";
export {
deployments,
type RetrieveCurrentDeploymentResponseBody,
type ApiDeploymentListResponseItem,
} from "./deployments.js";
export * as envvars from "./envvars.js";
export * as queues from "./queues.js";
export type { ImportEnvironmentVariablesParams } from "./envvars.js";
export { configure, auth } from "./auth.js";
export { TriggerClient, type TriggerClientConfig } from "./triggerClient.js";
export * as prompts from "./prompts.js";
export * as skills from "./skills.js";
+5
View File
@@ -0,0 +1,5 @@
import { type Locals, locals, type LocalsKey } from "@trigger.dev/core/v3";
export type { Locals, LocalsKey };
export { locals };
+249
View File
@@ -0,0 +1,249 @@
import type { DeserializedJson } from "@trigger.dev/core";
import {
type ApiRequestOptions,
mergeRequestOptions,
runMetadata,
type RunMetadataUpdater,
type AsyncIterableStream,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
import { streams } from "./streams.js";
const parentMetadataUpdater: RunMetadataUpdater = runMetadata.parent;
const rootMetadataUpdater: RunMetadataUpdater = runMetadata.root;
/**
* Provides access to run metadata operations.
* @namespace
* @property {Function} current - Get the current run's metadata.
* @property {Function} get - Get a specific key from the current run's metadata.
* @property {Function} set - Set a key in the current run's metadata.
* @property {Function} del - Delete a key from the current run's metadata.
* @property {Function} save - Update the entire metadata object for the current run.
*/
const metadataUpdater = {
set: setMetadataKey,
del: deleteMetadataKey,
append: appendMetadataKey,
remove: removeMetadataKey,
increment: incrementMetadataKey,
decrement: decrementMetadataKey,
flush: flushMetadata,
};
export const metadata = {
current: currentMetadata,
get: getMetadataKey,
save: saveMetadata,
replace: replaceMetadata,
stream: stream,
fetchStream: fetchStream,
parent: parentMetadataUpdater,
root: rootMetadataUpdater,
refresh: refreshMetadata,
...metadataUpdater,
};
export type RunMetadata = Record<string, DeserializedJson>;
/**
* Returns the metadata of the current run if inside a task run.
* This function allows you to access the entire metadata object for the current run.
*
* @returns {RunMetadata | undefined} The current run's metadata or undefined if not in a run context.
*
* @example
* const currentMetadata = metadata.current();
* console.log(currentMetadata);
*/
function currentMetadata(): RunMetadata | undefined {
return runMetadata.current();
}
/**
* Get a specific key from the metadata of the current run if inside a task run.
*
* @param {string} key - The key to retrieve from the metadata.
* @returns {DeserializedJson | undefined} The value associated with the key, or undefined if not found or not in a run context.
*
* @example
* const user = metadata.get("user");
* console.log(user.name); // "Eric"
* console.log(user.id); // "user_1234"
*/
function getMetadataKey(key: string): DeserializedJson | undefined {
return runMetadata.getKey(key);
}
/**
* Set a key in the metadata of the current run if inside a task run.
* This function allows you to update or add a new key-value pair to the run's metadata.
*
* @param {string} key - The key to set in the metadata.
* @param {DeserializedJson} value - The value to associate with the key.
*
* @example
* metadata.set("progress", 0.5);
*/
function setMetadataKey(key: string, value: DeserializedJson) {
runMetadata.set(key, value);
return metadataUpdater;
}
/**
* Delete a key from the metadata of the current run if inside a task run.
*
* @param {string} key - The key to delete from the metadata.
*
* @example
* metadata.del("progress");
*/
function deleteMetadataKey(key: string) {
runMetadata.del(key);
return metadataUpdater;
}
/**
* Update the entire metadata object for the current run if inside a task run.
* This function allows you to replace the entire metadata object with a new one.
*
* @param {RunMetadata} metadata - The new metadata object to set for the run.
* @returns {void}
*
* @example
* metadata.replace({ progress: 0.6, user: { name: "Alice", id: "user_5678" } });
*/
function replaceMetadata(metadata: RunMetadata) {
runMetadata.update(metadata);
}
/**
* @deprecated Use `metadata.replace()` instead.
*/
function saveMetadata(metadata: RunMetadata) {
runMetadata.update(metadata);
}
/**
* Increments a numeric value in the metadata of the current run by the specified amount.
* This function allows you to atomically increment a numeric metadata value.
*
* @param {string} key - The key of the numeric value to increment.
* @param {number} value - The amount to increment the value by.
*
* @example
* metadata.increment("counter", 1); // Increments counter by 1
* metadata.increment("score", 10); // Increments score by 10
*/
function incrementMetadataKey(key: string, value: number = 1) {
runMetadata.increment(key, value);
return metadataUpdater;
}
/**
* Decrements a numeric value in the metadata of the current run by the specified amount.
* This function allows you to atomically decrement a numeric metadata value.
*
* @param {string} key - The key of the numeric value to decrement.
* @param {number} value - The amount to decrement the value by.
*
* @example
* metadata.decrement("counter", 1); // Decrements counter by 1
* metadata.decrement("score", 5); // Decrements score by 5
*/
function decrementMetadataKey(key: string, value: number = 1) {
runMetadata.decrement(key, value);
return metadataUpdater;
}
/**
* Appends a value to an array in the metadata of the current run.
* If the key doesn't exist, it creates a new array with the value.
* If the key exists but isn't an array, it converts the existing value to an array.
*
* @param {string} key - The key of the array in metadata.
* @param {DeserializedJson} value - The value to append to the array.
*
* @example
* metadata.append("logs", "User logged in");
* metadata.append("events", { type: "click", timestamp: Date.now() });
*/
function appendMetadataKey(key: string, value: DeserializedJson) {
runMetadata.append(key, value);
return metadataUpdater;
}
/**
* Removes a value from an array in the metadata of the current run.
*
* @param {string} key - The key of the array in metadata.
* @param {DeserializedJson} value - The value to remove from the array.
*
* @example
*
* metadata.remove("logs", "User logged in");
* metadata.remove("events", { type: "click", timestamp: Date.now() });
*/
function removeMetadataKey(key: string, value: DeserializedJson) {
runMetadata.remove(key, value);
return metadataUpdater;
}
/**
* Flushes metadata to the Trigger.dev instance
*
* @param {ApiRequestOptions} [requestOptions] - Optional request options to customize the API request.
* @returns {Promise<void>} A promise that resolves when the metadata flush operation is complete.
*/
async function flushMetadata(requestOptions?: ApiRequestOptions): Promise<void> {
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "metadata.flush()",
icon: "code-plus",
},
requestOptions
);
await runMetadata.flush($requestOptions);
}
/**
* Refreshes metadata from the Trigger.dev instance
*
* @param {ApiRequestOptions} [requestOptions] - Optional request options to customize the API request.
* @returns {Promise<void>} A promise that resolves when the metadata refresh operation is complete.
*/
async function refreshMetadata(requestOptions?: ApiRequestOptions): Promise<void> {
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "metadata.refresh()",
icon: "code-plus",
},
requestOptions
);
await runMetadata.refresh($requestOptions);
}
/**
* @deprecated Use `streams.pipe()` instead.
*/
async function stream<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
signal?: AbortSignal
): Promise<AsyncIterable<T>> {
const streamInstance = await streams.pipe(key, value, {
signal,
});
return streamInstance.stream;
}
async function fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
return runMetadata.fetchStream<T>(key, signal);
}
+9
View File
@@ -0,0 +1,9 @@
import { metrics } from "@opentelemetry/api";
import { traceContext } from "@trigger.dev/core/v3";
export const otel = {
withExternalTrace: <T>(fn: () => T): T => {
return traceContext.withExternalTrace(fn);
},
metrics,
};
+251
View File
@@ -0,0 +1,251 @@
import {
accessoryAttributes,
apiClientManager,
resourceCatalog,
SemanticInternalAttributes,
type PromptMetadataWithFunctions,
type TaskSchema,
type inferSchemaIn,
getSchemaParseFn,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
type PromptOptions<
TIdentifier extends string = string,
TVariables extends TaskSchema | undefined = undefined,
> = {
id: TIdentifier;
description?: string;
model?: string;
config?: Record<string, unknown>;
variables?: TVariables;
content: string;
};
type ResolvedPrompt = {
promptId: string;
version: number;
labels: string[];
text: string;
model: string | undefined;
config: Record<string, unknown> | undefined;
/** Returns `experimental_telemetry` options for AI SDK calls (`generateText`, `streamText`, etc.) */
toAISDKTelemetry(additionalMetadata?: Record<string, string>): {
experimental_telemetry: {
isEnabled: true;
metadata: Record<string, string>;
};
};
};
export type { PromptOptions, ResolvedPrompt };
export type PromptHandle<
TIdentifier extends string = string,
TVariables extends TaskSchema | undefined = undefined,
> = {
id: TIdentifier;
resolve(
variables: inferSchemaIn<TVariables>,
options?: { label?: string; version?: number }
): Promise<ResolvedPrompt>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyPromptHandle = PromptHandle<string, any>;
/** Extract the identifier (id literal type) from a PromptHandle */
export type PromptIdentifier<T extends AnyPromptHandle> =
T extends PromptHandle<infer TId, any> ? TId : string;
/** Extract the variables input type from a PromptHandle */
export type PromptVariables<T extends AnyPromptHandle> =
T extends PromptHandle<any, infer TVariables>
? inferSchemaIn<TVariables>
: Record<string, unknown>;
/**
* Compile a Mustache-style template by substituting `{{variable}}` placeholders.
*/
function compileTemplate(template: string, variables: Record<string, unknown>): string {
// Handle conditional sections: {{#key}}...{{/key}}
let result = template.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_match, key, content) => {
const value = variables[key];
return value
? content.replace(/\{\{(\w+)\}\}/g, (_m: string, k: string) => {
return String(variables[k] ?? "");
})
: "";
});
// Handle simple substitutions: {{key}}
result = result.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
const value = variables[key];
return value !== undefined && value !== null ? String(value) : "";
});
return result;
}
function makeToAISDKTelemetry(
slug: string,
promptId: string,
version: number,
labels: string[],
model?: string,
input?: string
) {
return function toAISDKTelemetry(additionalMetadata?: Record<string, string>) {
return {
experimental_telemetry: {
isEnabled: true as const,
metadata: {
"prompt.slug": slug,
"prompt.id": promptId,
"prompt.version": String(version),
"prompt.labels": labels.join(", "),
...(model ? { "prompt.model": model } : {}),
...(input ? { "prompt.input": input } : {}),
...additionalMetadata,
},
},
};
};
}
function resolveLocally(
options: PromptOptions<any, any>,
variables: Record<string, unknown>
): ResolvedPrompt {
const inputJson = Object.keys(variables).length > 0 ? JSON.stringify(variables) : undefined;
const telemetryFn = makeToAISDKTelemetry(
options.id,
options.id,
0,
["local"],
options.model,
inputJson
);
return {
promptId: options.id,
version: 0,
labels: ["local"],
text: compileTemplate(options.content, variables),
model: options.model,
config: options.config,
toAISDKTelemetry: telemetryFn,
};
}
export function definePrompt<
TIdentifier extends string,
TVariables extends TaskSchema | undefined = undefined,
>(options: PromptOptions<TIdentifier, TVariables>): PromptHandle<TIdentifier, TVariables> {
const parseVariables = options.variables ? getSchemaParseFn(options.variables) : undefined;
// Register with resource catalog
const metadata: PromptMetadataWithFunctions = {
id: options.id,
description: options.description,
content: options.content,
model: options.model,
config: options.config,
variableSchema: undefined, // Set by CLI via schemaToJsonSchema
schema: options.variables,
fns: {
resolve: async (variables: Record<string, unknown>) => {
const validated = parseVariables ? await parseVariables(variables) : variables;
return resolveLocally(options, validated as Record<string, unknown>);
},
},
};
resourceCatalog.registerPromptMetadata(metadata);
return {
id: options.id,
resolve: async (variables, resolveOptions) => {
// Validate variables if schema provided
const validated = parseVariables ? await parseVariables(variables) : variables;
const vars = validated as Record<string, unknown>;
const apiClient = apiClientManager.client;
// Resolve via the API when a client is configured (inside tasks or via configure())
if (apiClient) {
const response = await apiClient.resolvePrompt(
options.id,
{
variables: vars,
label: resolveOptions?.label,
version: resolveOptions?.version,
},
{
tracer,
name: "prompt.resolve()",
icon: "tabler-file-text-ai",
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "tabler-file-text-ai",
[SemanticInternalAttributes.ENTITY_TYPE]: "prompt",
[SemanticInternalAttributes.ENTITY_ID]: options.id,
...accessoryAttributes({
items: [
{
text: options.id,
variant: "normal",
},
],
style: "codepath",
}),
},
onResponseBody: (body, span) => {
span.setAttribute("prompt.version", body.data.version);
span.setAttribute("prompt.slug", body.data.slug);
span.setAttribute("prompt.labels", body.data.labels.join(", "));
if (body.data.model) {
span.setAttribute("prompt.model", body.data.model);
}
if (body.data.template) {
span.setAttribute("prompt.template", body.data.template);
}
if (body.data.text) {
span.setAttribute("prompt.text", body.data.text);
}
if (body.data.config) {
span.setAttribute("prompt.config", JSON.stringify(body.data.config));
}
if (vars && Object.keys(vars).length > 0) {
span.setAttribute("prompt.input", JSON.stringify(vars));
}
},
}
);
const data = response.data;
const inputJson = vars && Object.keys(vars).length > 0 ? JSON.stringify(vars) : undefined;
const telemetryFn = makeToAISDKTelemetry(
data.slug,
data.promptId,
data.version,
data.labels,
data.model ?? undefined,
inputJson
);
return {
promptId: data.promptId,
version: data.version,
labels: data.labels,
text: data.text ?? "",
model: data.model ?? undefined,
config: (data.config as Record<string, unknown>) ?? undefined,
toAISDKTelemetry: telemetryFn,
};
}
// Fallback: resolve locally (outside platform or during dev)
return resolveLocally(options, vars);
},
};
}
@@ -0,0 +1,248 @@
import {
accessoryAttributes,
apiClientManager,
SemanticInternalAttributes,
type ApiRequestOptions,
type CreatePromptOverrideRequestBody,
type ListPromptsResponseBody,
type ListPromptVersionsResponseBody,
type PromptOkResponseBody,
type PromptOverrideCreatedResponseBody,
type UpdatePromptOverrideRequestBody,
} from "@trigger.dev/core/v3";
import type {
AnyPromptHandle,
PromptIdentifier,
PromptVariables,
ResolvedPrompt,
} from "./prompt.js";
import { tracer } from "./tracer.js";
function promptSpanOptions(name: string, slug: string) {
return {
tracer,
name,
icon: "tabler-file-text-ai",
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "tabler-file-text-ai",
...accessoryAttributes({
items: [{ text: slug, variant: "normal" as const }],
style: "codepath",
}),
},
};
}
function makeToAISDKTelemetry(
slug: string,
promptId: string,
version: number,
labels: string[],
model?: string,
input?: string
) {
return function toAISDKTelemetry(additionalMetadata?: Record<string, string>) {
return {
experimental_telemetry: {
isEnabled: true as const,
metadata: {
"prompt.slug": slug,
"prompt.id": promptId,
"prompt.version": String(version),
"prompt.labels": labels.join(", "),
...(model ? { "prompt.model": model } : {}),
...(input ? { "prompt.input": input } : {}),
...additionalMetadata,
},
},
};
};
}
/**
* Resolve a prompt by slug, calling the API to get the current version's
* compiled text. Works both inside and outside of a task context — requires
* an API client to be configured (via `configure()` or task runtime).
*/
export async function resolvePrompt<TPromptHandle extends AnyPromptHandle = AnyPromptHandle>(
slug: PromptIdentifier<TPromptHandle>,
variables?: PromptVariables<TPromptHandle>,
options?: { label?: string; version?: number; requestOptions?: ApiRequestOptions }
): Promise<ResolvedPrompt> {
const apiClient = apiClientManager.clientOrThrow();
const vars = variables ?? {};
const response = await apiClient.resolvePrompt(
slug,
{
variables: vars,
label: options?.label,
version: options?.version,
},
{
...promptSpanOptions("prompts.resolve()", slug),
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "tabler-file-text-ai",
[SemanticInternalAttributes.ENTITY_TYPE]: "prompt",
[SemanticInternalAttributes.ENTITY_ID]: slug,
...accessoryAttributes({
items: [{ text: slug, variant: "normal" as const }],
style: "codepath",
}),
},
onResponseBody: (body, span) => {
span.setAttribute("prompt.version", body.data.version);
span.setAttribute("prompt.slug", body.data.slug);
span.setAttribute("prompt.labels", body.data.labels.join(", "));
if (body.data.model) span.setAttribute("prompt.model", body.data.model);
if (body.data.text) span.setAttribute("prompt.text", body.data.text);
if (vars && Object.keys(vars).length > 0) {
span.setAttribute("prompt.input", JSON.stringify(vars));
}
},
}
);
const data = response.data;
const inputJson = Object.keys(vars).length > 0 ? JSON.stringify(vars) : undefined;
return {
promptId: data.promptId,
version: data.version,
labels: data.labels,
text: data.text ?? "",
model: data.model ?? undefined,
config: (data.config as Record<string, unknown>) ?? undefined,
toAISDKTelemetry: makeToAISDKTelemetry(
data.slug,
data.promptId,
data.version,
data.labels,
data.model ?? undefined,
inputJson
),
};
}
/** List all prompts in the current environment. */
export function listPrompts(requestOptions?: ApiRequestOptions): Promise<ListPromptsResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.listPrompts({
tracer,
name: "prompts.list()",
icon: "tabler-file-text-ai",
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "tabler-file-text-ai",
},
onResponseBody: (body, span) => {
span.setAttribute("prompt.count", body.data.length);
},
});
}
/** List all versions for a prompt. */
export function listPromptVersions(
slug: string,
requestOptions?: ApiRequestOptions
): Promise<ListPromptVersionsResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.listPromptVersions(slug, {
...promptSpanOptions("prompts.versions()", slug),
onResponseBody: (body, span) => {
span.setAttribute("prompt.slug", slug);
span.setAttribute("prompt.versions.count", body.data.length);
},
});
}
/** Promote a code-deployed version to be the current version. */
export async function promotePromptVersion(
slug: string,
version: number,
requestOptions?: ApiRequestOptions
): Promise<PromptOkResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.promotePromptVersion(
slug,
{ version },
{
...promptSpanOptions("prompts.promote()", slug),
attributes: {
...promptSpanOptions("prompts.promote()", slug).attributes,
"prompt.slug": slug,
"prompt.version": version,
},
}
);
}
/** Create an override — a dashboard/API edit that takes priority over the deployed version. */
export async function createPromptOverride(
slug: string,
body: CreatePromptOverrideRequestBody,
requestOptions?: ApiRequestOptions
): Promise<PromptOverrideCreatedResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.createPromptOverride(slug, body, {
...promptSpanOptions("prompts.createOverride()", slug),
attributes: {
...promptSpanOptions("prompts.createOverride()", slug).attributes,
"prompt.slug": slug,
...(body.model ? { "prompt.model": body.model } : {}),
},
onResponseBody: (body, span) => {
span.setAttribute("prompt.override.version", body.version);
},
});
}
/** Update the active override's content or model. */
export async function updatePromptOverride(
slug: string,
body: UpdatePromptOverrideRequestBody,
requestOptions?: ApiRequestOptions
): Promise<PromptOkResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.updatePromptOverride(slug, body, {
...promptSpanOptions("prompts.updateOverride()", slug),
attributes: {
...promptSpanOptions("prompts.updateOverride()", slug).attributes,
"prompt.slug": slug,
},
});
}
/** Remove the active override, reverting to the current deployed version. */
export async function removePromptOverride(
slug: string,
requestOptions?: ApiRequestOptions
): Promise<PromptOkResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.removePromptOverride(slug, {
...promptSpanOptions("prompts.removeOverride()", slug),
attributes: {
...promptSpanOptions("prompts.removeOverride()", slug).attributes,
"prompt.slug": slug,
},
});
}
/** Reactivate a previously removed override version. */
export async function reactivatePromptOverride(
slug: string,
version: number,
requestOptions?: ApiRequestOptions
): Promise<PromptOkResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.reactivatePromptOverride(
slug,
{ version },
{
...promptSpanOptions("prompts.reactivateOverride()", slug),
attributes: {
...promptSpanOptions("prompts.reactivateOverride()", slug).attributes,
"prompt.slug": slug,
"prompt.version": version,
},
}
);
}
+20
View File
@@ -0,0 +1,20 @@
export { definePrompt as define } from "./prompt.js";
export type {
AnyPromptHandle,
PromptHandle,
PromptIdentifier,
PromptOptions,
PromptVariables,
ResolvedPrompt,
} from "./prompt.js";
export {
resolvePrompt as resolve,
listPrompts as list,
listPromptVersions as versions,
promotePromptVersion as promote,
createPromptOverride as createOverride,
updatePromptOverride as updateOverride,
removePromptOverride as removeOverride,
reactivatePromptOverride as reactivateOverride,
} from "./promptManagement.js";
+179
View File
@@ -0,0 +1,179 @@
import type { ApiRequestOptions, Prettify } from "@trigger.dev/core/v3";
import { apiClientManager, mergeRequestOptions } from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
export type { QueryTable, RunsTableRow, RunFriendlyStatus } from "@trigger.dev/core/v3";
export type QueryScope = "environment" | "project" | "organization";
export type QueryFormat = "json" | "csv";
/**
* Options for executing a TRQL query
*/
export type QueryOptions = {
/**
* The scope of the query - determines what data is accessible
* - "environment": Current environment only (default)
* - "project": All environments in the project
* - "organization": All projects in the organization
*
* @default "environment"
*/
scope?: QueryScope;
/**
* Time period to query (e.g., "7d", "30d", "1h")
* Cannot be used with `from` or `to`
*/
period?: string;
/**
* Start of time range as a Date object or Unix timestamp in milliseconds.
* Must be used with `to`.
*/
from?: Date | number;
/**
* End of time range as a Date object or Unix timestamp in milliseconds.
* Must be used with `from`.
*/
to?: Date | number;
/**
* Response format
* - "json": Returns structured data (default)
* - "csv": Returns CSV string
*
* @default "json"
*/
format?: QueryFormat;
};
/**
* Execute a TRQL query and get the results as a CSV string.
*
* @param {string} query - The TRQL query string to execute
* @param {QueryOptions & { format: "csv" }} options - Query options with `format: "csv"`
* @param {ApiRequestOptions} [requestOptions] - Optional API request configuration
* @returns A promise resolving to `{ format: "csv", results: string }` where `results` is the raw CSV text
*
* @example
* ```typescript
* const csvResult = await query.execute(
* "SELECT run_id, status, triggered_at FROM runs",
* { format: "csv", period: "7d" }
* );
* const lines = csvResult.results.split('\n');
* ```
*/
function execute(
query: string,
options: QueryOptions & { format: "csv" },
requestOptions?: ApiRequestOptions
): Promise<{ format: "csv"; results: string }>;
/**
* Execute a TRQL query and return typed JSON rows.
*
* @template TRow - The shape of each row in the result set. Use {@link QueryTable} for type-safe column access (e.g. `QueryTable<"runs", "status" | "run_id">`)
* @param {string} query - The TRQL query string to execute
* @param {QueryOptions} [options] - Optional query configuration
* @param {ApiRequestOptions} [requestOptions] - Optional API request configuration
* @returns A promise resolving to `{ format: "json", results: Array<TRow> }`
*
* @example
* ```typescript
* // Basic query with defaults (environment scope, json format)
* const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");
* console.log(result.results); // Array<Record<string, any>>
*
* // Type-safe query using QueryTable with specific columns
* const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
* "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
* );
* typedResult.results.forEach(row => {
* console.log(row.run_id, row.status); // Fully typed!
* });
*
* // Inline type for aggregation queries
* const stats = await query.execute<{ status: string; count: number }>(
* "SELECT status, COUNT(*) as count FROM runs GROUP BY status"
* );
* stats.results.forEach(row => {
* console.log(row.status, row.count); // Fully type-safe
* });
*
* // Query with a custom time period
* const recent = await query.execute(
* "SELECT COUNT(*) as count FROM runs",
* { period: "3d" }
* );
* console.log(recent.results[0].count);
* ```
*/
function execute<TRow extends Record<string, any> = Record<string, any>>(
query: string,
options?: Omit<QueryOptions, "format"> | (QueryOptions & { format?: "json" }),
requestOptions?: ApiRequestOptions
): Promise<{ format: "json"; results: Array<Prettify<TRow>> }>;
// Implementation
function execute<TRow extends Record<string, any> = Record<string, any>>(
query: string,
options?: QueryOptions,
requestOptions?: ApiRequestOptions
): Promise<{ format: "json"; results: Array<TRow> } | { format: "csv"; results: string }> {
const apiClient = apiClientManager.clientOrThrow();
const from = dateToISOString(options?.from);
const to = dateToISOString(options?.to);
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "query.execute()",
icon: "query",
attributes: {
scope: options?.scope ?? "environment",
format: options?.format ?? "json",
query,
period: options?.period,
from,
to,
},
},
requestOptions
);
return apiClient
.executeQuery(
query,
{
scope: options?.scope,
period: options?.period,
from,
to,
format: options?.format,
},
$requestOptions
)
.then((response) => {
return response;
}) as Promise<{ format: "json"; results: Array<TRow> } | { format: "csv"; results: string }>;
}
function dateToISOString(date: Date | number | undefined): string | undefined {
if (date === undefined) {
return undefined;
}
if (date instanceof Date) {
return date.toISOString();
}
return new Date(date).toISOString();
}
export const query = {
execute,
};
+253
View File
@@ -0,0 +1,253 @@
import type {
ApiPromise,
ApiRequestOptions,
ListQueueOptions,
OffsetLimitPagePromise,
QueueItem,
RetrieveQueueParam,
} from "@trigger.dev/core/v3";
import {
accessoryAttributes,
apiClientManager,
flattenAttributes,
mergeRequestOptions,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
/**
* Lists queues
* @param options - The list options
* @param options.page - The page number
* @param options.perPage - The number of queues per page
* @returns The list of queues
*/
export function list(
options?: ListQueueOptions,
requestOptions?: ApiRequestOptions
): OffsetLimitPagePromise<typeof QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.list()",
icon: "queue",
},
requestOptions
);
return apiClient.listQueues(options, $requestOptions);
}
/**
* When retrieving a queue you can either use the queue id,
* or the type and name.
*
* @example
*
* ```ts
* // Use a queue id (they start with queue_
* const q1 = await queues.retrieve("queue_12345");
*
* // Or use the type and name
* // The default queue for your "my-task-id"
* const q2 = await queues.retrieve({ type: "task", name: "my-task-id"});
*
* // The custom queue you defined in your code
* const q3 = await queues.retrieve({ type: "custom", name: "my-custom-queue" });
* ```
* @param queue - The ID of the queue to retrieve, or the type and name
* @returns The retrieved queue
*/
export function retrieve(
queue: RetrieveQueueParam,
requestOptions?: ApiRequestOptions
): ApiPromise<QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.retrieve()",
icon: "queue",
attributes: {
...flattenAttributes({ queue }),
...accessoryAttributes({
items: [
{
text: typeof queue === "string" ? queue : queue.name,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.retrieveQueue(queue, $requestOptions);
}
/**
* Pauses a queue, preventing any new runs from being started.
* Runs that are currently running will continue to completion.
*
* @example
* ```ts
* // Pause using a queue id
* await queues.pause("queue_12345");
*
* // Or pause using type and name
* await queues.pause({ type: "task", name: "my-task-id"});
* ```
* @param queue - The ID of the queue to pause, or the type and name
* @returns The updated queue state
*/
export function pause(
queue: RetrieveQueueParam,
requestOptions?: ApiRequestOptions
): ApiPromise<QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.pause()",
icon: "queue",
attributes: {
...flattenAttributes({ queue }),
...accessoryAttributes({
items: [
{
text: typeof queue === "string" ? queue : queue.name,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.pauseQueue(queue, "pause", $requestOptions);
}
/**
* Overrides the concurrency limit of a queue.
*
* @param queue - The ID of the queue to override the concurrency limit, or the type and name
* @param concurrencyLimit - The concurrency limit to override
* @returns The updated queue state
*/
export function overrideConcurrencyLimit(
queue: RetrieveQueueParam,
concurrencyLimit: number,
requestOptions?: ApiRequestOptions
): ApiPromise<QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.overrideConcurrencyLimit()",
icon: "queue",
attributes: {
...flattenAttributes({ queue }),
...accessoryAttributes({
items: [
{
text: typeof queue === "string" ? queue : queue.name,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.overrideQueueConcurrencyLimit(queue, concurrencyLimit, $requestOptions);
}
/**
* Resets the concurrency limit of a queue to the base value.
*
* @param queue - The ID of the queue to reset the concurrency limit, or the type and name
* @returns The updated queue state
*/
export function resetConcurrencyLimit(
queue: RetrieveQueueParam,
requestOptions?: ApiRequestOptions
): ApiPromise<QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.resetConcurrencyLimit()",
icon: "queue",
attributes: {
...flattenAttributes({ queue }),
...accessoryAttributes({
items: [
{
text: typeof queue === "string" ? queue : queue.name,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.resetQueueConcurrencyLimit(queue, $requestOptions);
}
/**
* Resumes a paused queue, allowing new runs to be started.
*
* @example
* ```ts
* // Resume using a queue id
* await queues.resume("queue_12345");
*
* // Or resume using type and name
* await queues.resume({ type: "task", name: "my-task-id"});
* ```
* @param queue - The ID of the queue to resume, or the type and name
* @returns The updated queue state
*/
export function resume(
queue: RetrieveQueueParam,
requestOptions?: ApiRequestOptions
): ApiPromise<QueueItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "queues.resume()",
icon: "queue",
attributes: {
...flattenAttributes({ queue }),
...accessoryAttributes({
items: [
{
text: typeof queue === "string" ? queue : queue.name,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.pauseQueue(queue, "resume", $requestOptions);
}
+616
View File
@@ -0,0 +1,616 @@
import type { Attributes, Span } from "@opentelemetry/api";
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
import {
SEMATTRS_HTTP_HOST,
SEMATTRS_HTTP_METHOD,
SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH,
SEMATTRS_HTTP_SCHEME,
SEMATTRS_HTTP_STATUS_CODE,
SEMATTRS_HTTP_URL,
} from "@opentelemetry/semantic-conventions";
import type {
FetchRetryByStatusOptions,
FetchRetryOptions,
FetchRetryStrategy,
RetryOptions,
} from "@trigger.dev/core/v3";
import {
SemanticInternalAttributes,
accessoryAttributes,
calculateNextRetryDelay,
calculateResetAt,
defaultFetchRetryOptions,
defaultRetryOptions,
eventFilterMatches,
flattenAttributes,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
import { wait } from "./wait.js";
export type { RetryOptions };
function onThrow<T>(
fn: (options: { attempt: number; maxAttempts: number }) => Promise<T>,
options: RetryOptions
): Promise<T> {
const opts = {
...defaultRetryOptions,
...options,
};
return tracer.startActiveSpan(
`retry.onThrow()`,
async (span) => {
let attempt = 1;
while (attempt <= opts.maxAttempts) {
const innerSpan = tracer.startSpan("retry.fn()", {
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "function",
...accessoryAttributes({
items: [
{
text: `${attempt}/${opts.maxAttempts}`,
variant: "normal",
},
],
style: "codepath",
}),
},
});
const contextWithSpanSet = trace.setSpan(context.active(), innerSpan);
try {
const result = await context.with(contextWithSpanSet, async () => {
return fn({ attempt, maxAttempts: opts.maxAttempts });
});
innerSpan.end();
return result;
} catch (e) {
if (e instanceof Error || typeof e === "string") {
innerSpan.recordException(e);
} else {
innerSpan.recordException(String(e));
}
innerSpan.setStatus({ code: SpanStatusCode.ERROR });
if (e instanceof Error && e.name === "AbortTaskRunError") {
innerSpan.end();
throw e;
}
const nextRetryDelay = calculateNextRetryDelay(opts, attempt);
if (!nextRetryDelay) {
innerSpan.end();
throw e;
}
innerSpan.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(Date.now() + nextRetryDelay).toISOString()
);
innerSpan.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
innerSpan.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
innerSpan.end();
await wait.until({ date: new Date(Date.now() + nextRetryDelay) });
} finally {
attempt++;
}
}
throw new Error("Max attempts reached");
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "arrow-capsule",
},
}
);
}
export interface RetryFetchRequestInit extends RequestInit {
retry?: FetchRetryOptions;
timeoutInMs?: number;
}
const normalizeUrlFromInput = (input: RequestInfo | URL | string): URL => {
if (typeof input === "string") {
return new URL(input);
}
if (input instanceof URL) {
return input;
}
return new URL(input.url);
};
const normalizeHttpMethod = (input: RequestInfo | URL | string, init?: RequestInit): string => {
if (typeof input === "string" || input instanceof URL) {
return (init?.method || "GET").toUpperCase();
}
return (input.method ?? init?.method ?? "GET").toUpperCase();
};
class FetchErrorWithSpan extends Error {
constructor(
public readonly originalError: unknown,
public readonly span: Span
) {
super("Fetch error");
}
}
const MAX_ATTEMPTS = 10;
async function retryFetch(
input: RequestInfo | URL,
init?: RetryFetchRequestInit | undefined
): Promise<Response> {
return tracer.startActiveSpan(
"retry.fetch()",
async (span) => {
let attempt = 1;
while (true) {
try {
const abortController = new AbortController();
const timeoutId = init?.timeoutInMs
? setTimeout(() => {
abortController.abort();
}, init?.timeoutInMs)
: undefined;
init?.signal?.addEventListener("abort", () => {
abortController.abort();
});
const [response, span] = await doFetchRequest(
input,
{ ...(init ?? {}), signal: abortController.signal },
attempt
);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (response.ok) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
}
const nextRetry = await calculateRetryDelayForResponse(
resolveDefaults(init?.retry, "byStatus", defaultFetchRetryOptions.byStatus),
response,
attempt
);
if (!nextRetry) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
}
if (attempt >= MAX_ATTEMPTS) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
}
if (nextRetry.type === "delay") {
const continueDate = new Date(Date.now() + nextRetry.value);
span.setAttribute(SemanticInternalAttributes.RETRY_AT, continueDate.toISOString());
span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetry.value}ms`);
span.end();
await wait.until({ date: continueDate });
} else {
const now = Date.now();
const nextRetryDate = new Date(nextRetry.value);
const isInFuture = nextRetryDate.getTime() > now;
span.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(nextRetry.value).toISOString()
);
span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
if (isInFuture) {
span.setAttribute(
SemanticInternalAttributes.RETRY_DELAY,
`${nextRetry.value - now}ms`
);
}
span.end();
await wait.until({ date: new Date(nextRetry.value) });
}
} catch (e) {
if (e instanceof FetchErrorWithSpan && e.originalError instanceof Error) {
if (e.originalError.name === "AbortError") {
const nextRetryDelay = calculateNextRetryDelay(
resolveDefaults(init?.retry, "timeout", defaultFetchRetryOptions.timeout),
attempt
);
if (!nextRetryDelay) {
e.span.end();
throw e;
}
if (attempt >= MAX_ATTEMPTS) {
e.span.end();
throw e;
}
const continueDate = new Date(Date.now() + nextRetryDelay);
e.span.setAttribute(SemanticInternalAttributes.RETRY_AT, continueDate.toISOString());
e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
e.span.end();
await wait.until({ date: continueDate });
continue; // Move to the next attempt
} else if (
e.originalError.name === "TypeError" &&
"cause" in e.originalError &&
e.originalError.cause instanceof Error
) {
const nextRetryDelay = calculateNextRetryDelay(
resolveDefaults(
init?.retry,
"connectionError",
defaultFetchRetryOptions.connectionError
),
attempt
);
if (!nextRetryDelay) {
e.span.end();
throw e;
}
if (attempt >= MAX_ATTEMPTS) {
e.span.end();
throw e;
}
e.span.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(Date.now() + nextRetryDelay).toISOString()
);
e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
e.span.end();
await wait.until({ date: new Date(Date.now() + nextRetryDelay) });
continue; // Move to the next attempt
}
}
if (e instanceof FetchErrorWithSpan) {
e.span.end();
}
throw e;
} finally {
attempt++;
}
}
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "arrow-capsule",
...createFetchAttributes(input, init),
...createFetchRetryOptionsAttributes(init?.retry),
},
}
);
}
const doFetchRequest = async (
input: RequestInfo | URL | string,
init?: RequestInit,
attemptCount: number = 0
): Promise<[Response, Span]> => {
const httpMethod = normalizeHttpMethod(input, init);
const span = tracer.startSpan(`HTTP ${httpMethod}`, {
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "world",
...(attemptCount > 1 ? { ["http.request.resend_count"]: attemptCount - 1 } : {}),
...createFetchAttributes(input, init),
},
});
try {
const response = await fetch(input, {
...init,
headers: {
...init?.headers,
"x-retry-count": attemptCount.toString(),
},
});
span.setAttributes(createFetchResponseAttributes(response));
if (!response.ok) {
span.recordException(`${response.status}: ${response.statusText}`);
span.setStatus({
code: SpanStatusCode.ERROR,
message: `${response.status}: ${response.statusText}`,
});
}
return [response, span];
} catch (e) {
if (typeof e === "string" || e instanceof Error) {
span.recordException(e);
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, 0);
span.setAttribute("http.status_text", "This operation was aborted.");
throw new FetchErrorWithSpan(e, span);
}
};
const calculateRetryDelayForResponse = async (
retry: FetchRetryByStatusOptions | undefined,
response: Response,
attemptCount: number
): Promise<{ type: "delay"; value: number } | { type: "timestamp"; value: number } | undefined> => {
if (!retry) {
return;
}
const strategy = await getRetryStrategyForResponse(response, retry);
if (!strategy) {
return;
}
switch (strategy.strategy) {
case "backoff": {
const value = calculateNextRetryDelay({ ...defaultRetryOptions, ...strategy }, attemptCount);
if (value) {
return { type: "delay", value };
}
break;
}
case "headers": {
const resetAt = response.headers.get(strategy.resetHeader);
if (typeof resetAt === "string") {
const resetTimestamp = calculateResetAt(
resetAt,
strategy.resetFormat ?? "unix_timestamp_in_ms"
);
if (resetTimestamp) {
return { type: "timestamp", value: resetTimestamp };
}
}
break;
}
}
return;
};
const getRetryStrategyForResponse = async (
response: Response,
retry: FetchRetryByStatusOptions
): Promise<FetchRetryStrategy | undefined> => {
const statusCodes = Object.keys(retry);
const clonedResponse = response.clone();
for (let i = 0; i < statusCodes.length; i++) {
const statusRange = statusCodes[i];
if (!statusRange) {
continue;
}
const strategy = retry[statusRange];
if (!strategy) {
continue;
}
if (isStatusCodeInRange(response.status, statusRange)) {
if (strategy.bodyFilter) {
const body = safeJsonParse(await clonedResponse.text());
if (!body) {
continue;
}
if (eventFilterMatches(body, strategy.bodyFilter)) {
return strategy;
} else {
continue;
}
}
return strategy;
}
}
return;
};
/**
* Checks if a given status code falls within a given range.
* The range can be a single status code (e.g. "200"),
* a range of status codes (e.g. "500-599"),
* a range of status codes with a wildcard (e.g. "4xx" for any 4xx status code),
* or a list of status codes separated by commas (e.g. "401,403,404,409-412,5xx").
* Returns `true` if the status code falls within the range, and `false` otherwise.
*/
const isStatusCodeInRange = (statusCode: number, statusRange: string): boolean => {
if (statusRange === "all") {
return true;
}
if (statusRange.includes(",")) {
const statusCodes = statusRange.split(",").map((s) => s.trim());
return statusCodes.some((s) => isStatusCodeInRange(statusCode, s));
}
const [start, end] = statusRange.split("-");
if (end) {
return statusCode >= parseInt(start ?? "0", 10) && statusCode <= parseInt(end, 10);
}
if (start?.endsWith("xx")) {
const prefix = start.slice(0, -2);
const statusCodePrefix = Math.floor(statusCode / 100).toString();
return statusCodePrefix === prefix;
}
if (!start) {
return false;
}
const statusCodeString = statusCode.toString();
const rangePrefix = start.slice(0, -1);
if (start.endsWith("x") && statusCodeString.startsWith(rangePrefix)) {
return true;
}
return statusCode === parseInt(start, 10);
};
const createAttributesFromHeaders = (headers: Headers): Attributes => {
const attributes: Attributes = {};
const normalizedHeaderKey = (key: string) => {
return key.toLowerCase();
};
headers.forEach((value, key) => {
attributes[`http.response.header.${normalizedHeaderKey(key)}`] = value;
});
return attributes;
};
const safeJsonParse = (json: string): unknown => {
try {
return JSON.parse(json);
} catch (_e) {
return null;
}
};
// This function will resolve the defaults of a property within an options object.
// If the options object is undefined, it will return the defaults for that property (passed in as the 3rd arg).
// if the options object is defined, and the property exists, then it will return the defaults if the value of the property is undefined or null
const resolveDefaults = <
TObject extends Record<string, unknown>,
K extends keyof TObject,
TValue extends TObject[K],
>(
obj: TObject | undefined,
key: K,
defaults: TValue
): TValue => {
if (!obj) {
return defaults;
}
if (obj[key] === undefined || obj[key] === null) {
return defaults;
}
return obj[key] as TValue;
};
const createFetchAttributes = (
input: RequestInfo | URL,
init?: RetryFetchRequestInit | undefined
): Attributes => {
const url = normalizeUrlFromInput(input);
const httpMethod = normalizeHttpMethod(input, init);
return {
[SEMATTRS_HTTP_METHOD]: httpMethod,
[SEMATTRS_HTTP_URL]: url.href,
[SEMATTRS_HTTP_HOST]: url.hostname,
["server.host"]: url.hostname,
["server.port"]: url.port,
[SEMATTRS_HTTP_SCHEME]: url.protocol.replace(":", ""),
...accessoryAttributes({
items: [
{
text: url.hostname,
variant: "normal",
},
],
style: "codepath",
}),
};
};
const createFetchResponseAttributes = (response: Response): Attributes => {
return {
[SEMATTRS_HTTP_STATUS_CODE]: response.status,
"http.status_text": response.statusText,
[SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: response.headers.get("content-length") || "0",
...createAttributesFromHeaders(response.headers),
};
};
const createFetchRetryOptionsAttributes = (retry?: FetchRetryOptions): Attributes => {
const byStatus = resolveDefaults(retry, "byStatus", defaultFetchRetryOptions.byStatus);
const connectionError = resolveDefaults(
retry,
"connectionError",
defaultFetchRetryOptions.connectionError
);
const timeout = resolveDefaults(retry, "timeout", defaultFetchRetryOptions.timeout);
return {
...flattenAttributes(byStatus, "retry.byStatus"),
...flattenAttributes(connectionError, "retry.connectionError"),
...flattenAttributes(timeout, "retry.timeout"),
};
};
export const retry = {
onThrow,
fetch: retryFetch,
};
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { apiClientManager } from "@trigger.dev/core/v3";
import { runs } from "./runs.js";
type ReceivedRequest = {
method: string;
url: string;
headers: IncomingMessage["headers"];
body: string;
};
type RequestHandler = (request: ReceivedRequest, response: ServerResponse) => void | Promise<void>;
describe("runs.bulk", () => {
let server: Server;
let baseUrl: string;
let receivedRequests: ReceivedRequest[] = [];
let requestHandler: RequestHandler | undefined;
beforeEach(async () => {
receivedRequests = [];
requestHandler = undefined;
server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", async () => {
const received = {
method: req.method ?? "",
url: req.url ?? "",
headers: req.headers,
body: Buffer.concat(chunks).toString(),
} satisfies ReceivedRequest;
receivedRequests.push(received);
try {
if (requestHandler) {
await requestHandler(received, res);
} else {
json(res, { error: "No handler" }, 500);
}
} catch (error) {
json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
resolve();
});
});
});
afterEach(async () => {
apiClientManager.disable();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("creates a cancel bulk action", async () => {
requestHandler = (_request, response) => json(response, { id: "bulk_cancel" });
const result = await withApiClient(() =>
runs.bulk.cancel({ runIds: ["run_1", "run_2"], name: "Cancel selected" })
);
expect(result).toEqual({ id: "bulk_cancel" });
expect(receivedRequests[0]?.method).toBe("POST");
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
action: "cancel",
runIds: ["run_1", "run_2"],
name: "Cancel selected",
});
});
it("creates a replay bulk action", async () => {
requestHandler = (_request, response) => json(response, { id: "bulk_replay" });
const result = await withApiClient(() =>
runs.bulk.replay({
filter: { status: "FAILED", taskIdentifier: ["task-a", "task-b"] },
name: "Replay failed tasks",
targetRegion: "eu_1",
})
);
expect(result).toEqual({ id: "bulk_replay" });
expect(receivedRequests[0]?.method).toBe("POST");
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
action: "replay",
filter: { status: "FAILED", taskIdentifier: ["task-a", "task-b"] },
name: "Replay failed tasks",
targetRegion: "eu_1",
});
});
it("retrieves and aborts bulk actions", async () => {
requestHandler = (request, response) => {
if (request.method === "GET") {
return json(response, bulkActionObject("bulk_read", "PENDING"));
}
return json(response, { id: "bulk_read" });
};
const retrieved = await withApiClient(() => runs.bulk.retrieve("bulk_read"));
const aborted = await withApiClient(() => runs.bulk.abort("bulk_read"));
expect(retrieved.id).toBe("bulk_read");
expect(retrieved.createdAt).toEqual(new Date("2026-07-01T10:00:00.000Z"));
expect(aborted).toEqual({ id: "bulk_read" });
expect(receivedRequests.map((request) => `${request.method} ${request.url}`)).toEqual([
"GET /api/v1/bulk-actions/bulk_read",
"POST /api/v1/bulk-actions/bulk_read/abort",
]);
});
it("lists bulk actions", async () => {
requestHandler = (_request, response) =>
json(response, {
data: [bulkActionObject("bulk_listed", "COMPLETED")],
pagination: { next: "cursor_next" },
});
const page = await withApiClient(() => runs.bulk.list({ limit: 1, before: "cursor_before" }));
const url = new URL(receivedRequests[0]?.url ?? "", baseUrl);
expect(receivedRequests[0]?.method).toBe("GET");
expect(url.pathname).toBe("/api/v1/bulk-actions");
expect(url.searchParams.get("page[size]")).toBe("1");
expect(url.searchParams.get("page[before]")).toBe("cursor_before");
expect(page.data[0]?.id).toBe("bulk_listed");
expect(page.pagination.next).toBe("cursor_next");
});
it("polls until the bulk action finishes", async () => {
requestHandler = (_request, response) => {
const status = receivedRequests.length === 1 ? "PENDING" : "COMPLETED";
return json(response, bulkActionObject("bulk_poll", status));
};
const bulkAction = await withApiClient(() =>
runs.bulk.poll("bulk_poll", { pollIntervalMs: 1 })
);
expect(bulkAction.status).toBe("COMPLETED");
expect(receivedRequests.map((request) => request.url)).toEqual([
"/api/v1/bulk-actions/bulk_poll",
"/api/v1/bulk-actions/bulk_poll",
]);
});
function withApiClient<T>(fn: () => Promise<T>) {
return apiClientManager.runWithConfig(
{ baseURL: baseUrl, accessToken: "tr_test_key" },
async () => fn()
);
}
});
function json(response: ServerResponse, body: unknown, status = 200) {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
}
function bulkActionObject(id: string, status: "PENDING" | "COMPLETED" | "ABORTED") {
return {
id,
type: "REPLAY",
status,
counts: { total: 2, success: status === "COMPLETED" ? 2 : 0, failure: 0 },
createdAt: "2026-07-01T10:00:00.000Z",
completedAt: status === "COMPLETED" ? "2026-07-01T10:05:00.000Z" : undefined,
};
}
+678
View File
@@ -0,0 +1,678 @@
import type {
AnyRetrieveRunResult,
AnyRunShape,
ApiRequestOptions,
CreateBulkCancelActionOptions,
CreateBulkReplayActionOptions,
InferRunTypes,
ListBulkActionsQueryParams,
ListProjectRunsQueryParams,
ListRunsQueryParams,
RescheduleRunRequestBody,
RetrieveRunResult,
RunShape,
RealtimeRun,
AnyRealtimeRun,
RunSubscription,
TaskRunShape,
AnyBatchedRunHandle,
AsyncIterableStream,
ApiPromise,
RealtimeRunSkipColumns,
AbortBulkActionResponseBody,
BulkActionObject,
CanceledRunResponse,
CreateBulkActionResponseBody,
CursorPagePromise,
ListRunResponseItem,
ReplayRunResponse,
RetrieveRunResponse,
} from "@trigger.dev/core/v3";
import {
accessoryAttributes,
apiClientManager,
flattenAttributes,
isRequestOptions,
mergeRequestOptions,
} from "@trigger.dev/core/v3";
import { resolvePresignedPacketUrl } from "@trigger.dev/core/v3/utils/ioSerialization";
import type { AnyRunHandle, AnyTask } from "./shared.js";
import { tracer } from "./tracer.js";
export type {
AnyRetrieveRunResult,
AnyRunShape,
RetrieveRunResult,
RunShape,
TaskRunShape,
RealtimeRun,
AnyRealtimeRun,
};
export const runs = {
replay: replayRun,
cancel: cancelRun,
retrieve: retrieveRun,
list: listRuns,
reschedule: rescheduleRun,
bulk: {
cancel: bulkCancelRuns,
replay: bulkReplayRuns,
retrieve: retrieveBulkAction,
abort: abortBulkAction,
list: listBulkActions,
poll: pollBulkAction,
},
poll,
subscribeToRun,
subscribeToRunsWithTag,
subscribeToBatch: subscribeToRunsInBatch,
fetchStream,
};
export type ListRunsItem = ListRunResponseItem;
export type BulkAction = BulkActionObject;
function listRuns(
projectRef: string,
params?: ListProjectRunsQueryParams,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof ListRunResponseItem>;
function listRuns(
params?: ListRunsQueryParams,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof ListRunResponseItem>;
function listRuns(
paramsOrProjectRef?: ListRunsQueryParams | string,
paramsOrOptions?: ListRunsQueryParams | ListProjectRunsQueryParams | ApiRequestOptions,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof ListRunResponseItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = listRunsRequestOptions(
paramsOrProjectRef,
paramsOrOptions,
requestOptions
);
if (typeof paramsOrProjectRef === "string") {
if (isRequestOptions(paramsOrOptions)) {
return apiClient.listProjectRuns(paramsOrProjectRef, {}, $requestOptions);
} else {
return apiClient.listProjectRuns(paramsOrProjectRef, paramsOrOptions, $requestOptions);
}
}
return apiClient.listRuns(paramsOrProjectRef, $requestOptions);
}
function listRunsRequestOptions(
paramsOrProjectRef?: ListRunsQueryParams | string,
paramsOrOptions?: ListRunsQueryParams | ListProjectRunsQueryParams | ApiRequestOptions,
requestOptions?: ApiRequestOptions
): ApiRequestOptions {
if (typeof paramsOrProjectRef === "string") {
if (isRequestOptions(paramsOrOptions)) {
return mergeRequestOptions(
{
tracer,
name: "runs.list()",
icon: "runs",
attributes: {
projectRef: paramsOrProjectRef,
...accessoryAttributes({
items: [
{
text: paramsOrProjectRef,
variant: "normal",
},
],
style: "codepath",
}),
},
},
paramsOrOptions
);
} else {
return mergeRequestOptions(
{
tracer,
name: "runs.list()",
icon: "runs",
attributes: {
projectRef: paramsOrProjectRef,
...flattenAttributes(paramsOrOptions as Record<string, unknown>, "queryParams"),
...accessoryAttributes({
items: [
{
text: paramsOrProjectRef,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
}
}
return mergeRequestOptions(
{
tracer,
name: "runs.list()",
icon: "runs",
attributes: {
...flattenAttributes(paramsOrProjectRef as Record<string, unknown>, "queryParams"),
},
},
isRequestOptions(paramsOrOptions) ? paramsOrOptions : requestOptions
);
}
// Extract out the expected type of the id, can be either a string or a RunHandle
type RunId<TRunId> = TRunId extends AnyRunHandle | AnyBatchedRunHandle
? TRunId
: TRunId extends AnyTask
? string
: TRunId extends string
? TRunId
: never;
function retrieveRun<TRunId extends AnyRunHandle | AnyBatchedRunHandle | AnyTask | string>(
runId: RunId<TRunId>,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveRunResult<TRunId>> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.retrieve()",
icon: "runs",
attributes: {
runId: typeof runId === "string" ? runId : runId.id,
...accessoryAttributes({
items: [
{
text: typeof runId === "string" ? runId : runId.id,
variant: "normal",
},
],
style: "codepath",
}),
},
prepareData: resolvePayloadAndOutputUrls,
},
requestOptions
);
const $runId = typeof runId === "string" ? runId : runId.id;
return apiClient.retrieveRun($runId, $requestOptions) as ApiPromise<RetrieveRunResult<TRunId>>;
}
async function resolvePayloadAndOutputUrls(run: AnyRetrieveRunResult) {
const resolvedRun = { ...run };
if (run.payloadPresignedUrl && run.outputPresignedUrl) {
const [payload, output] = await Promise.all([
resolvePresignedPacketUrl(run.payloadPresignedUrl, tracer),
resolvePresignedPacketUrl(run.outputPresignedUrl, tracer),
]);
resolvedRun.payload = payload;
resolvedRun.output = output;
} else if (run.payloadPresignedUrl) {
resolvedRun.payload = await resolvePresignedPacketUrl(run.payloadPresignedUrl, tracer);
} else if (run.outputPresignedUrl) {
resolvedRun.output = await resolvePresignedPacketUrl(run.outputPresignedUrl, tracer);
}
return resolvedRun;
}
function replayRun(
runId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<ReplayRunResponse> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.replay()",
icon: "runs",
attributes: {
runId,
...accessoryAttributes({
items: [
{
text: runId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.replayRun(runId, $requestOptions);
}
function cancelRun(
runId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<CanceledRunResponse> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.cancel()",
icon: "runs",
attributes: {
runId,
...accessoryAttributes({
items: [
{
text: runId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.cancelRun(runId, $requestOptions);
}
function bulkCancelRuns(
options: CreateBulkCancelActionOptions,
requestOptions?: ApiRequestOptions
): ApiPromise<CreateBulkActionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.bulk.cancel()",
icon: "runs",
attributes: {
...flattenAttributes(options as Record<string, unknown>, "bulkAction"),
},
},
requestOptions
);
return apiClient.createBulkAction({ ...options, action: "cancel" }, $requestOptions);
}
function bulkReplayRuns(
options: CreateBulkReplayActionOptions,
requestOptions?: ApiRequestOptions
): ApiPromise<CreateBulkActionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.bulk.replay()",
icon: "runs",
attributes: {
...flattenAttributes(options as Record<string, unknown>, "bulkAction"),
},
},
requestOptions
);
return apiClient.createBulkAction({ ...options, action: "replay" }, $requestOptions);
}
function retrieveBulkAction(
bulkActionId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<BulkActionObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.bulk.retrieve()",
icon: "runs",
attributes: {
bulkActionId,
...accessoryAttributes({
items: [{ text: bulkActionId, variant: "normal" }],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.retrieveBulkAction(bulkActionId, $requestOptions);
}
function abortBulkAction(
bulkActionId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<AbortBulkActionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.bulk.abort()",
icon: "runs",
attributes: {
bulkActionId,
...accessoryAttributes({
items: [{ text: bulkActionId, variant: "normal" }],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.abortBulkAction(bulkActionId, $requestOptions);
}
function listBulkActions(
params?: ListBulkActionsQueryParams,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof BulkActionObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.bulk.list()",
icon: "runs",
attributes: {
...flattenAttributes(params as Record<string, unknown>, "queryParams"),
},
},
requestOptions
);
return apiClient.listBulkActions(params, $requestOptions);
}
async function pollBulkAction(
bulkActionId: string,
options?: { pollIntervalMs?: number },
requestOptions?: ApiRequestOptions
): Promise<BulkActionObject> {
let attempts = 0;
while (attempts++ < MAX_POLL_ATTEMPTS) {
const bulkAction = await retrieveBulkAction(bulkActionId, requestOptions);
if (bulkAction.status !== "PENDING") {
return bulkAction;
}
await new Promise((resolve) => setTimeout(resolve, options?.pollIntervalMs ?? 1000));
}
throw new Error(`Bulk action ${bulkActionId} did not finish after ${MAX_POLL_ATTEMPTS} attempts`);
}
function rescheduleRun(
runId: string,
body: RescheduleRunRequestBody,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveRunResponse> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "runs.reschedule()",
icon: "runs",
attributes: {
runId,
...accessoryAttributes({
items: [
{
text: runId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.rescheduleRun(runId, body, $requestOptions);
}
export type PollOptions = { pollIntervalMs?: number };
const MAX_POLL_ATTEMPTS = 500;
async function poll<TRunId extends AnyRunHandle | AnyTask | string>(
runId: RunId<TRunId>,
options?: { pollIntervalMs?: number },
requestOptions?: ApiRequestOptions
) {
let attempts = 0;
while (attempts++ < MAX_POLL_ATTEMPTS) {
const run = await runs.retrieve(runId, requestOptions);
if (run.isCompleted) {
return run;
}
await new Promise((resolve) => setTimeout(resolve, options?.pollIntervalMs ?? 1000));
}
throw new Error(
`Run ${
typeof runId === "string" ? runId : runId.id
} did not complete after ${MAX_POLL_ATTEMPTS} attempts`
);
}
export type SubscribeToRunOptions = {
/**
* Whether to close the subscription when the run completes
*
* @default true
*
* Set this to false if you are making updates to the run metadata after completion through child runs
*/
stopOnCompletion?: boolean;
/**
* Skip columns from the subscription.
*
* @default []
*
* @example
* ```ts
* runs.subscribeToRun("123", { skipColumns: ["payload", "output"] });
* ```
*/
skipColumns?: RealtimeRunSkipColumns;
/**
* An AbortSignal to cancel the subscription.
*
* When the signal is aborted, the underlying SSE connection is closed
* and the async iterator completes.
*/
signal?: AbortSignal;
};
/**
* Subscribes to real-time updates for a specific run.
*
* This function allows you to receive real-time updates whenever a run changes, including:
* - Status changes in the run lifecycle
* - Tag additions or removals
* - Metadata updates
*
* @template TRunId - The type parameter extending AnyRunHandle, AnyTask, or string
* @param {RunId<TRunId>} runId - The ID of the run to subscribe to. Can be a string ID, RunHandle, or Task
* @param {SubscribeToRunOptions} [options] - Optional configuration for the subscription
* @param {boolean} [options.stopOnCompletion=true] - Whether to close the subscription when the run completes
* @returns {RunSubscription<InferRunTypes<TRunId>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe using a run handle
* const handle = await tasks.trigger("my-task", { some: "data" });
* for await (const run of runs.subscribeToRun(handle.id)) {
* console.log("Run updated:", run);
* }
*
* // Subscribe with type safety
* for await (const run of runs.subscribeToRun<typeof myTask>(runId)) {
* console.log("Payload:", run.payload.some);
* if (run.output) {
* console.log("Output:", run.output);
* }
* }
* ```
*/
function subscribeToRun<TRunId extends AnyRunHandle | AnyTask | string>(
runId: RunId<TRunId>,
options?: SubscribeToRunOptions
): RunSubscription<InferRunTypes<TRunId>> {
const $runId = typeof runId === "string" ? runId : runId.id;
const apiClient = apiClientManager.clientOrThrow();
return apiClient.subscribeToRun($runId, {
closeOnComplete:
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
skipColumns: options?.skipColumns,
signal: options?.signal,
});
}
export type SubscribeToRunsFilterOptions = {
/**
* Filter runs by the time they were created. You must specify the duration string like "1h", "10s", "30m", etc.
*
* @example
* "1h" - 1 hour ago
* "10s" - 10 seconds ago
* "30m" - 30 minutes ago
* "1d" - 1 day ago
* "1w" - 1 week ago
*
* The maximum duration is 1 week
*
* @note The timestamp will be calculated on the server side when you first subscribe to the runs.
*
*/
createdAt?: string;
/**
* Skip columns from the subscription.
*
* @default []
*/
skipColumns?: RealtimeRunSkipColumns;
};
/**
* Subscribes to real-time updates for all runs that have specific tags.
*
* This function allows you to monitor multiple runs simultaneously by filtering on tags.
* You'll receive updates whenever any run with the specified tag(s) changes.
*
* @template TTasks - The type parameter extending AnyTask for type-safe payload and output
* @param {string | string[]} tag - A single tag or array of tags to filter runs
* @returns {RunSubscription<InferRunTypes<TTasks>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe to runs with a single tag
* for await (const run of runs.subscribeToRunsWithTag("user:1234")) {
* console.log("Run updated:", run);
* }
*
* // Subscribe with multiple tags and type safety
* for await (const run of runs.subscribeToRunsWithTag<typeof myTask | typeof otherTask>(["tag1", "tag2"])) {
* switch (run.taskIdentifier) {
* case "my-task":
* console.log("MyTask output:", run.output.foo);
* break;
* case "other-task":
* console.log("OtherTask output:", run.output.bar);
* break;
* }
* }
* ```
*/
function subscribeToRunsWithTag<TTasks extends AnyTask>(
tag: string | string[],
filters?: SubscribeToRunsFilterOptions,
options?: { signal?: AbortSignal }
): RunSubscription<InferRunTypes<TTasks>> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.subscribeToRunsWithTag<InferRunTypes<TTasks>>(tag, filters, {
...(options ? { signal: options.signal } : {}),
});
}
/**
* Subscribes to real-time updates for all runs within a specific batch.
*
* Use this function when you've triggered multiple runs using `batchTrigger` and want
* to monitor all runs in that batch. You'll receive updates whenever any run in the batch changes.
*
* @template TTasks - The type parameter extending AnyTask for type-safe payload and output
* @param {string} batchId - The ID of the batch to subscribe to
* @returns {RunSubscription<InferRunTypes<TTasks>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe to all runs in a batch
* for await (const run of runs.subscribeToRunsInBatch("batch-123")) {
* console.log("Batch run updated:", run);
* }
*
* // Subscribe with type safety
* for await (const run of runs.subscribeToRunsInBatch<typeof myTask>("batch-123")) {
* console.log("Run payload:", run.payload);
* if (run.output) {
* console.log("Run output:", run.output);
* }
* }
* ```
*
* @note The run objects received will include standard fields like id, status, payload, output,
* createdAt, updatedAt, tags, and more. See the Run object documentation for full details.
*/
function subscribeToRunsInBatch<TTasks extends AnyTask>(
batchId: string
): RunSubscription<InferRunTypes<TTasks>> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.subscribeToBatch<InferRunTypes<TTasks>>(batchId);
}
/**
* Fetches a stream of data from a run's stream key.
*/
async function fetchStream<T>(runId: string, streamKey: string): Promise<AsyncIterableStream<T>> {
const apiClient = apiClientManager.clientOrThrow();
return await apiClient.fetchStream(runId, streamKey);
}
@@ -0,0 +1,6 @@
export type {
CreateScheduleOptions,
ScheduledTaskPayload,
ListScheduleOptions,
UpdateScheduleOptions,
} from "@trigger.dev/core/v3";
@@ -0,0 +1,355 @@
import type {
ApiPromise,
ApiRequestOptions,
DeletedScheduleObject,
InitOutput,
OffsetLimitPagePromise,
ScheduleObject,
} from "@trigger.dev/core/v3";
import {
TimezonesResult,
accessoryAttributes,
apiClientManager,
mergeRequestOptions,
resourceCatalog,
} from "@trigger.dev/core/v3";
import { zodfetch } from "@trigger.dev/core/v3/zodfetch";
import type { Task, TaskOptions } from "../shared.js";
import { createTask } from "../shared.js";
import type * as SchedulesAPI from "./api.js";
import { tracer } from "../tracer.js";
export type ScheduleOptions<
TIdentifier extends string,
TOutput,
TInitOutput extends InitOutput,
> = TaskOptions<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput, TInitOutput> & {
/** You can optionally specify a CRON schedule on your task. You can also dynamically add a schedule in the dashboard or using the SDK functions.
*
* 1. Pass a CRON pattern string
* ```ts
* "0 0 * * *"
* ```
*
* 2. Or an object with a pattern, optional timezone, and optional environments
* ```ts
* {
* pattern: "0 0 * * *",
* timezone: "America/Los_Angeles",
* environments: ["PRODUCTION", "STAGING"]
* }
* ```
*
* @link https://trigger.dev/docs/v3/tasks-scheduled
*/
cron?:
| string
| {
pattern: string;
timezone?: string;
/** You can optionally specify which environments this schedule should run in.
* When not specified, the schedule will run in all environments.
*
* @example
* ```ts
* environments: ["PRODUCTION", "STAGING"]
* ```
*
* @example
* ```ts
* environments: ["PRODUCTION"] // Only run in production
* ```
*/
environments?: Array<"DEVELOPMENT" | "STAGING" | "PRODUCTION" | "PREVIEW">;
};
};
export function task<TIdentifier extends string, TOutput, TInitOutput extends InitOutput>(
params: ScheduleOptions<TIdentifier, TOutput, TInitOutput>
): Task<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput> {
const task = createTask(params);
const cron = params.cron
? typeof params.cron === "string"
? params.cron
: params.cron.pattern
: undefined;
const timezone =
(params.cron && typeof params.cron !== "string" ? params.cron.timezone : "UTC") ?? "UTC";
const environments =
params.cron && typeof params.cron !== "string" ? params.cron.environments : undefined;
resourceCatalog.updateTaskMetadata(task.id, {
triggerSource: "schedule",
schedule: cron
? {
cron: cron,
timezone,
environments,
}
: undefined,
});
return task;
}
/**
* Creates a new schedule
* @param options
* @param options.task - The identifier of the task to be scheduled (Must already exist and be a scheduled task)
* @param options.cron - The cron expression for the schedule (e.g. `0 0 * * *`)
* @param options.timezone - An optional timezone for the schedule in the IANA format (e.g. `America/Los_Angeles`). Defaults to "UTC".
* @param options.externalId - An optional external identifier for the schedule
* @param options.deduplicationKey - An optional deduplication key for the schedule
* @returns The created schedule
*/
export function create(
options: SchedulesAPI.CreateScheduleOptions,
requestOptions?: ApiRequestOptions
): ApiPromise<ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.create()",
icon: "clock",
attributes: {
...accessoryAttributes({
items: [
{
text: options.cron,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.createSchedule(options, $requestOptions);
}
/**
* Retrieves a schedule
* @param scheduleId - The ID of the schedule to retrieve
* @returns The retrieved schedule
*/
export function retrieve(
scheduleId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.retrieve()",
icon: "clock",
attributes: {
scheduleId,
...accessoryAttributes({
items: [
{
text: scheduleId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.retrieveSchedule(scheduleId, $requestOptions);
}
/**
* Updates a schedule
* @param scheduleId - The ID of the schedule to update
* @param options - The updated schedule options
* @param options.task - The identifier of the task to be scheduled (Must already exist and be a scheduled task)
* @param options.cron - The cron expression for the schedule (e.g. `0 0 * * *`)
* @param options.timezone - An optional timezone for the schedule in the IANA format (e.g. `America/Los_Angeles`). Defaults to "UTC".
* @param options.externalId - An optional external identifier for the schedule
* @returns The updated schedule
*/
export function update(
scheduleId: string,
options: SchedulesAPI.UpdateScheduleOptions,
requestOptions?: ApiRequestOptions
): ApiPromise<ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.update()",
icon: "clock",
attributes: {
scheduleId,
...accessoryAttributes({
items: [
{
text: scheduleId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.updateSchedule(scheduleId, options, $requestOptions);
}
/**
* Deletes a schedule
* @param scheduleId - The ID of the schedule to delete
*/
export function del(
scheduleId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<DeletedScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.delete()",
icon: "clock",
attributes: {
scheduleId,
...accessoryAttributes({
items: [
{
text: scheduleId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.deleteSchedule(scheduleId, $requestOptions);
}
/**
* Deactivates a schedule
* @param scheduleId - The ID of the schedule to deactivate
*/
export function deactivate(
scheduleId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.deactivate()",
icon: "clock",
attributes: {
scheduleId,
...accessoryAttributes({
items: [
{
text: scheduleId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.deactivateSchedule(scheduleId, $requestOptions);
}
/**
* Activates a schedule
* @param scheduleId - The ID of the schedule to activate
*/
export function activate(
scheduleId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.activate()",
icon: "clock",
attributes: {
scheduleId,
...accessoryAttributes({
items: [
{
text: scheduleId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.activateSchedule(scheduleId, $requestOptions);
}
/**
* Lists schedules
* @param options - The list options
* @param options.page - The page number
* @param options.perPage - The number of schedules per page
* @returns The list of schedules
*/
export function list(
options?: SchedulesAPI.ListScheduleOptions,
requestOptions?: ApiRequestOptions
): OffsetLimitPagePromise<typeof ScheduleObject> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "schedules.list()",
icon: "clock",
},
requestOptions
);
return apiClient.listSchedules(options, $requestOptions);
}
/**
* Lists the possible timezones we support
* @param excludeUtc - By default "UTC" is included and is first. If true, "UTC" will be excluded.
*/
export function timezones(options?: { excludeUtc?: boolean }) {
const baseUrl = apiClientManager.baseURL;
return zodfetch(
TimezonesResult,
`${baseUrl}/api/v1/timezones${options?.excludeUtc === true ? "?excludeUtc=true" : ""}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export JSON Schema types for user convenience
export type { JSONSchema } from "@trigger.dev/core/v3";
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from "vitest";
// Per-test override for the stubbed SessionStreamInstance's wait() so a
// test can simulate downstream writer failures (e.g. S2 auth error after
// initializeSessionStream returned a stale token). Reset at the top of
// each test that touches it.
let stubWaitImpl: (() => Promise<{ lastEventId?: string }>) | undefined;
// Stub `SessionStreamInstance` so constructing a channel writer doesn't try
// to reach S2. The stub still invokes the `initializeSession` callback the
// channel passes in, which is the whole point: that's how the cache gets
// exercised. wait() resolves immediately by default; tests can override it
// via `stubWaitImpl` to verify reactive invalidation on writer failure.
vi.mock("@trigger.dev/core/v3", async (importActual) => {
const actual = (await importActual()) as Record<string, unknown>;
class StubSessionStreamInstance<T> {
private waitPromise: Promise<{ lastEventId?: string }>;
constructor(opts: {
source: ReadableStream<T>;
initializeSession?: () => Promise<{ headers?: Record<string, string> }>;
}) {
// Drain the source so the upstream tee doesn't backpressure-stall.
void (async () => {
const reader = opts.source.getReader();
try {
while (true) {
const { done } = await reader.read();
if (done) break;
}
} finally {
reader.releaseLock();
}
})();
// Trigger the initializeSession callback so the cache path runs.
opts.initializeSession?.().catch(() => {
// Failures are observed via the spy; swallow here so unhandled
// rejection warnings don't leak through the stub.
});
// Capture the wait outcome once at construction (mirrors real
// SessionStreamInstance which kicks off initializeWriter from the
// ctor). All subsequent wait() calls return the same promise so
// a single failure is observable by every consumer in the channel
// (`.finally`, reactive `.catch`, and customer `waitUntilComplete`).
this.waitPromise = stubWaitImpl
? stubWaitImpl()
: Promise.resolve({ lastEventId: undefined });
// Claim any rejection so test runs don't surface as unhandled.
// Real awaiters still observe the rejection when they `await` it.
this.waitPromise.catch(() => {});
}
async wait() {
return this.waitPromise;
}
get stream() {
return new ReadableStream<T>({ start: (c) => c.close() });
}
}
return { ...actual, SessionStreamInstance: StubSessionStreamInstance };
});
import { SessionOutputChannel } from "./sessions.js";
import { apiClientManager } from "@trigger.dev/core/v3";
type ApiClientStub = {
initializeSessionStream: ReturnType<typeof vi.fn>;
};
function installStubApiClient(impl: ApiClientStub["initializeSessionStream"]): ApiClientStub {
const stub: ApiClientStub = { initializeSessionStream: impl };
// `apiClientManager.clientOrThrow()` is what `#pipeInternal` reaches for.
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(
stub as unknown as ReturnType<typeof apiClientManager.clientOrThrow>
);
return stub;
}
function emptyStream(): ReadableStream<unknown> {
return new ReadableStream({ start: (c) => c.close() });
}
describe("SessionOutputChannel initializeSessionStream cache", () => {
it("dedupes repeated pipe()/writer() calls for the same channel", async () => {
stubWaitImpl = undefined;
const spy = vi.fn(async () => ({ version: "v2", headers: {} }));
installStubApiClient(spy);
const channel = new SessionOutputChannel("session-1");
const p1 = channel.pipe(emptyStream());
const p2 = channel.pipe(emptyStream());
const p3 = channel.writer({
execute: ({ write }) => {
write({ chunk: 1 });
},
});
await Promise.all([p1.waitUntilComplete(), p2.waitUntilComplete(), p3.waitUntilComplete()]);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("session-1", "out", undefined);
});
it("evicts on initialize failure so the next call retries instead of returning a poisoned entry", async () => {
stubWaitImpl = undefined;
const spy = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({ version: "v2", headers: {} });
installStubApiClient(spy);
const channel = new SessionOutputChannel("session-1");
const firstAttempt = channel.pipe(emptyStream());
// First call fails — the stub swallows the rejection on the
// initializeSession callback, but the cache eviction handler still runs.
await firstAttempt.waitUntilComplete();
// Settle pending microtasks so the .catch() eviction fires.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const retried = channel.pipe(emptyStream());
await retried.waitUntilComplete();
expect(spy).toHaveBeenCalledTimes(2);
});
it("reset() clears cached entries so the next call re-PUTs", async () => {
stubWaitImpl = undefined;
const spy = vi.fn(async () => ({ version: "v2", headers: {} }));
installStubApiClient(spy);
const channel = new SessionOutputChannel("session-1");
await channel.pipe(emptyStream()).waitUntilComplete();
expect(spy).toHaveBeenCalledTimes(1);
channel.reset();
await channel.pipe(emptyStream()).waitUntilComplete();
expect(spy).toHaveBeenCalledTimes(2);
});
it("scopes the cache per channel instance", async () => {
stubWaitImpl = undefined;
const spy = vi.fn(async () => ({ version: "v2", headers: {} }));
installStubApiClient(spy);
const channelA = new SessionOutputChannel("session-a");
const channelB = new SessionOutputChannel("session-b");
await Promise.all([
channelA.pipe(emptyStream()).waitUntilComplete(),
channelB.pipe(emptyStream()).waitUntilComplete(),
]);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith("session-a", "out", undefined);
expect(spy).toHaveBeenCalledWith("session-b", "out", undefined);
});
it("evicts the cache when a writer's wait() rejects (simulated stale-token failure)", async () => {
const spy = vi.fn(async () => ({ version: "v2", headers: {} }));
installStubApiClient(spy);
// First writer's wait() rejects (e.g. S2 returned 401 after the cached
// token expired mid-process); subsequent writers' wait() resolve cleanly.
let waitCallCount = 0;
stubWaitImpl = async () => {
waitCallCount++;
if (waitCallCount === 1) throw new Error("S2 auth failed: token expired");
return { lastEventId: undefined };
};
const channel = new SessionOutputChannel("session-1");
const failed = channel.pipe(emptyStream());
await expect(failed.waitUntilComplete()).rejects.toThrow(/token expired/);
// Settle microtasks so the reactive .catch eviction handler fires.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const recovered = channel.pipe(emptyStream());
await recovered.waitUntilComplete();
// Cache evicted ⇒ second pipe() re-PUT ⇒ two distinct initialize calls.
expect(spy).toHaveBeenCalledTimes(2);
stubWaitImpl = undefined;
});
});
+899
View File
@@ -0,0 +1,899 @@
import { SpanStatusCode } from "@opentelemetry/api";
import type {
ApiPromise,
ApiRequestOptions,
AsyncIterableStream,
CloseSessionRequestBody,
ControlEvent,
CreateSessionRequestBody,
CreatedSessionResponseBody,
InitializeSessionStreamResponseLike,
InputStreamOnceOptions,
InputStreamOnceResult,
InputStreamWaitOptions,
InputStreamWaitWithIdleTimeoutOptions,
ListSessionsOptions,
ListedSessionItem,
PipeStreamOptions,
PipeStreamResult,
RetrieveSessionResponseBody,
StreamWriteResult,
UpdateSessionRequestBody,
WriterStreamOptions,
CursorPagePromise,
} from "@trigger.dev/core/v3";
import {
InputStreamOncePromise,
ManualWaitpointPromise,
SemanticInternalAttributes,
SessionStreamInstance,
WaitpointTimeoutError,
accessoryAttributes,
apiClientManager,
ensureReadableStream,
mergeRequestOptions,
runtime,
sessionStreams,
taskContext,
trimSessionStream,
writeSessionControlRecord,
} from "@trigger.dev/core/v3";
import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
import { tracer } from "./tracer.js";
export type {
CloseSessionRequestBody,
CreateSessionRequestBody,
CreatedSessionResponseBody,
ListSessionsOptions,
ListedSessionItem,
RetrieveSessionResponseBody,
UpdateSessionRequestBody,
};
export const sessions = {
start: startSession,
retrieve: retrieveSession,
update: updateSession,
close: closeSession,
list: listSessions,
open,
};
// Test hook: lets `@trigger.dev/sdk/ai/test` replace `sessions.open()` with
// an in-memory handle so unit tests don't hit the network. Not part of the
// public API — only `mockChatAgent` installs it.
type SessionOpenImpl = (sessionIdOrExternalId: string) => SessionHandle;
let sessionOpenImpl: SessionOpenImpl | undefined;
export function __setSessionOpenImplForTests(impl: SessionOpenImpl | undefined): void {
sessionOpenImpl = impl;
}
// Test hook for `sessions.start()`. Sessions are task-bound and the
// `start` call atomically creates the row + triggers the first run on
// the server; in unit tests there's no live API to hit, so a fixture
// implementation can be installed via this setter.
type SessionStartImpl = (
body: CreateSessionRequestBody
) => Promise<CreatedSessionResponseBody> | CreatedSessionResponseBody;
let sessionStartImpl: SessionStartImpl | undefined;
export function __setSessionStartImplForTests(impl: SessionStartImpl | undefined): void {
sessionStartImpl = impl;
}
/**
* Start a {@link Session} — a stateful execution of an agent, with
* two-way streaming and durable compute, that can span multiple runs.
* The server creates the row (idempotent on `externalId`)
* and triggers the first run from `triggerConfig` in one round-trip.
* Returns the new run's id and a session-scoped public access token
* for browser-side use against `.in/append`, `.out` SSE, and
* `end-and-continue`.
*
* If a session with the same `(env, externalId)` already exists,
* returns the existing row plus the live (or freshly re-triggered) run.
* Two browser tabs of the same chat converge to one session.
*/
function startSession(
body: CreateSessionRequestBody,
requestOptions?: ApiRequestOptions
): ApiPromise<CreatedSessionResponseBody> {
if (sessionStartImpl) {
const result = sessionStartImpl(body);
return Promise.resolve(result) as ApiPromise<CreatedSessionResponseBody>;
}
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "sessions.start()",
icon: "sessions",
attributes: sessionAttributes(body.externalId ?? body.type, {
type: body.type,
...(body.externalId ? { externalId: body.externalId } : {}),
}),
},
requestOptions
);
return apiClient.createSession(body, $requestOptions);
}
/**
* Retrieve a Session by `friendlyId` (`session_*`) or user-supplied
* `externalId`. The server disambiguates via the `session_` prefix.
*/
function retrieveSession(
sessionIdOrExternalId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveSessionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "sessions.retrieve()",
icon: "sessions",
attributes: sessionAttributes(sessionIdOrExternalId),
},
requestOptions
);
return apiClient.retrieveSession(sessionIdOrExternalId, $requestOptions);
}
/** Update mutable fields on a Session (tags, metadata, externalId). */
function updateSession(
sessionIdOrExternalId: string,
body: UpdateSessionRequestBody,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveSessionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "sessions.update()",
icon: "sessions",
attributes: sessionAttributes(sessionIdOrExternalId),
},
requestOptions
);
return apiClient.updateSession(sessionIdOrExternalId, body, $requestOptions);
}
/** Mark a Session as closed (terminal, idempotent). */
function closeSession(
sessionIdOrExternalId: string,
body?: CloseSessionRequestBody,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveSessionResponseBody> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "sessions.close()",
icon: "sessions",
attributes: sessionAttributes(sessionIdOrExternalId, {
...(body?.reason ? { reason: body.reason } : {}),
}),
},
requestOptions
);
return apiClient.closeSession(sessionIdOrExternalId, body, $requestOptions);
}
/**
* List Sessions in the current environment with filters + cursor pagination.
* Returns a {@link CursorPagePromise} so callers can iterate pages with
* `for await`.
*/
function listSessions(
options?: ListSessionsOptions,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof ListedSessionItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "sessions.list()",
icon: "sessions",
attributes: {
...(options?.type ? { type: toAttr(options.type) } : {}),
...(options?.tag ? { tag: toAttr(options.tag) } : {}),
...(options?.status ? { status: toAttr(options.status) } : {}),
...(options?.externalId ? { externalId: options.externalId } : {}),
},
},
requestOptions
);
return apiClient.listSessions(options, $requestOptions);
}
/**
* Open a lightweight handle to a Session's realtime channels. Does not
* perform a network call on its own — each channel method hits the
* corresponding realtime endpoint.
*/
function open(sessionIdOrExternalId: string): SessionHandle {
if (sessionOpenImpl) return sessionOpenImpl(sessionIdOrExternalId);
return new SessionHandle(sessionIdOrExternalId);
}
export class SessionHandle {
/**
* Producer-to-consumer channel: the task writes records; external
* clients read them. Mirrors `streams.define` — `append` / `pipe` /
* `writer` / `read`.
*/
public readonly out: SessionOutputChannel;
/**
* Consumer-to-producer channel: external clients call `.send()`; the
* task consumes via `.on` / `.once` / `.peek` / `.wait` /
* `.waitWithIdleTimeout`. Mirrors `streams.input` but keyed on the
* session so a conversation can survive across run boundaries.
*/
public readonly in: SessionInputChannel;
constructor(
public readonly id: string,
overrides?: { in?: SessionInputChannel; out?: SessionOutputChannel }
) {
this.out = overrides?.out ?? new SessionOutputChannel(id);
this.in = overrides?.in ?? new SessionInputChannel(id);
}
}
/**
* Options accepted by {@link SessionOutputChannel.pipe}. Session-scoped,
* so it omits the `target` field (self/parent/root/runId) that run-scoped
* {@link PipeStreamOptions} uses — the session is the target.
*/
export type SessionPipeStreamOptions = Omit<PipeStreamOptions, "target">;
/**
* The `.out` side of a Session's bidirectional channel pair. Mirrors the
* consume-side of {@link streams.define}: `pipe` / `writer` / `append`
* for the task to produce records, `read` for external clients to
* consume via SSE. S2 credentials for direct writes are fetched
* internally by `pipe`/`writer` — there's no public `initialize()`.
*/
export class SessionOutputChannel {
// Cache of the in-flight / resolved `initializeSessionStream` PUT for
// this channel. Every `pipe()` / `writer()` call needs the same S2
// credentials, so we share a single promise instead of re-PUTing on
// every chunk. Hot-loop writers (per-chunk `chat.response.write` /
// direct `session.out.writer` calls) drop from N PUTs to 1 PUT for
// the lifetime of the channel. The S2 access token has a 1-day TTL
// server-side so reusing it across calls within a single run is safe.
// Evicts on failure (so the next call retries) and on `reset()`.
#initPromise?: Promise<InitializeSessionStreamResponseLike>;
constructor(public readonly sessionId: string) {}
/**
* Drop the cached `initializeSessionStream` response. Surfaces for
* tests and lifecycle hooks that need the next write to re-mint S2
* credentials. The cache also self-evicts on `initializeSession`
* rejection, so callers don't need to invoke this on failures.
*
* @internal
*/
reset(): void {
this.#initPromise = undefined;
}
/**
* Append a single record. Routes through {@link writer} internally so
* subscribers receive the same parsed-object shape as multi-record
* writes — the server-side append endpoint wraps the body in a string,
* which would give SSE consumers a JSON-string instead of an object.
* Mirrors how `streams.define.append` delegates to `streams.writer`.
*/
async append<T>(value: T, options?: SessionPipeStreamOptions): Promise<void> {
const { waitUntilComplete } = this.writer<T>({
...options,
spanName: "sessions.append()",
execute: ({ write }) => {
write(value);
},
});
await waitUntilComplete();
}
/**
* Pipe an `AsyncIterable` / `ReadableStream` directly to S2. Fetches
* session S2 credentials internally and streams through
* {@link SessionStreamInstance}. Parallel to {@link streams.pipe} but
* session-scoped — no `target` option because the session is the target.
*/
pipe<T>(
value: AsyncIterable<T> | ReadableStream<T>,
options?: SessionPipeStreamOptions
): PipeStreamResult<T> {
return this.#pipeInternal(value, options, "sessions.pipe()");
}
/**
* Mirror of {@link streams.writer}: runs `execute({ write, merge })`
* against an in-memory queue whose records are piped to S2. Returns
* `{ stream, waitUntilComplete }` so callers can observe the local
* stream and await completion. Span is collapsible via `options.spanName`
* / `options.collapsed`.
*/
writer<T>(options: WriterStreamOptions<T>): PipeStreamResult<T> {
let controller!: ReadableStreamDefaultController<T>;
const ongoingStreamPromises: Promise<void>[] = [];
const stream = new ReadableStream<T>({
start(controllerArg) {
controller = controllerArg;
},
});
const safeEnqueue = (data: T) => {
try {
controller.enqueue(data);
} catch {
// Suppress errors when the stream has been closed.
}
};
try {
const result = options.execute({
write(part) {
safeEnqueue(part);
},
merge(streamArg) {
ongoingStreamPromises.push(
(async () => {
const reader = streamArg.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
safeEnqueue(value);
}
})().catch((error) => {
console.error(error);
})
);
},
});
if (result) {
ongoingStreamPromises.push(
result.catch((error) => {
console.error(error);
})
);
}
} catch (error) {
console.error(error);
}
const waitForStreams: Promise<void> = new Promise((resolve, reject) => {
(async () => {
while (ongoingStreamPromises.length > 0) {
await ongoingStreamPromises.shift();
}
resolve();
})().catch(reject);
});
waitForStreams.finally(() => {
try {
controller.close();
} catch {
// Already closed.
}
});
return this.#pipeInternal(stream, options, options.spanName ?? "sessions.writer()");
}
/**
* Subscribe to SSE records on `.out`. Returns an async-iterable stream —
* auto-retry, Last-Event-ID resume, and abort propagation come from the
* shared {@link SSEStreamSubscription} plumbing used by run-scoped
* realtime streams.
*/
async read<T = unknown>(options?: SessionSubscribeOptions<T>): Promise<AsyncIterableStream<T>> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.subscribeToSessionStream<T>(this.sessionId, "out", {
signal: options?.signal,
timeoutInSeconds: options?.timeoutInSeconds,
lastEventId: options?.lastEventId != null ? String(options.lastEventId) : undefined,
onPart: options?.onPart,
onControl: options?.onControl,
onComplete: options?.onComplete,
onError: options?.onError,
});
}
#pipeInternal<T>(
value: AsyncIterable<T> | ReadableStream<T>,
options: SessionPipeStreamOptions | undefined,
spanName: string
): PipeStreamResult<T> {
const apiClient = apiClientManager.clientOrThrow();
const collapsed = (options as WriterStreamOptions<T> | undefined)?.collapsed;
const span = tracer.startSpan(spanName, {
attributes: {
session: this.sessionId,
io: "out",
[SemanticInternalAttributes.ENTITY_TYPE]: "session-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${this.sessionId}:out`,
[SemanticInternalAttributes.STYLE_ICON]: "sessions",
...(collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}),
...accessoryAttributes({
items: [{ text: `${this.sessionId}.out`, variant: "normal" }],
style: "codepath",
}),
},
});
const readableStreamSource = ensureReadableStream(value);
const abortController = new AbortController();
// `AbortSignal.any` lands in Node 20.3; the SDK still supports Node
// 18.20+. On older runtimes fall back to wiring `options.signal` into
// `abortController` manually so caller-driven cancellation propagates.
let combinedSignal: AbortSignal = abortController.signal;
// Set in the Node 18 fallback path so the caller's `signal.addEventListener`
// registration can be cleared once the stream finishes. Without this, a
// long-lived caller signal (e.g. one reused across many `writer()` calls)
// accumulates listeners on every completed turn.
let removeCallerAbortListener: (() => void) | undefined;
if (options?.signal) {
if (typeof AbortSignal.any === "function") {
combinedSignal = AbortSignal.any([options.signal, abortController.signal]);
} else {
const callerSignal = options.signal;
if (callerSignal.aborted) {
abortController.abort(callerSignal.reason);
} else {
const onCallerAbort = () => abortController.abort(callerSignal.reason);
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
removeCallerAbortListener = () =>
callerSignal.removeEventListener("abort", onCallerAbort);
}
}
}
// Resolve the init promise eagerly so we can capture which one this
// writer uses for reactive invalidation below.
const writerInitPromise = ((): Promise<InitializeSessionStreamResponseLike> => {
if (this.#initPromise) {
return this.#initPromise;
}
const fresh = apiClient.initializeSessionStream(
this.sessionId,
"out",
options?.requestOptions
);
this.#initPromise = fresh;
// Evict on failure so the next call retries instead of returning a
// poisoned cache entry forever.
fresh.catch((err) => {
if (this.#initPromise === fresh) {
this.#initPromise = undefined;
}
});
return fresh;
})();
try {
const instance = new SessionStreamInstance<T>({
apiClient,
baseUrl: apiClientManager.baseURL ?? "",
sessionId: this.sessionId,
io: "out",
source: readableStreamSource,
signal: combinedSignal,
requestOptions: options?.requestOptions,
initializeSession: () => writerInitPromise,
});
// Single internal chain that handles span lifecycle AND reactive
// invalidation. On rejection we evict the cached init promise so
// the next pipe()/writer() re-PUTs and recovers (e.g. when a
// cached S2 access token expired mid-process). Compare by identity
// so a concurrent caller's fresh promise isn't accidentally cleared.
// Customer awaiters still observe the rejection via the returned
// `waitUntilComplete()`; this chain just keeps the cleanup path
// from surfacing as unhandled.
instance.wait().then(
() => {
removeCallerAbortListener?.();
span.end();
},
() => {
removeCallerAbortListener?.();
if (this.#initPromise === writerInitPromise) {
this.#initPromise = undefined;
}
span.end();
}
);
return {
stream: instance.stream,
waitUntilComplete: async () => {
return instance.wait();
},
};
} catch (error) {
removeCallerAbortListener?.();
if (error instanceof Error && error.name === "AbortError") {
span.end();
throw error;
}
if (error instanceof Error || typeof error === "string") {
span.recordException(error);
} else {
span.recordException(String(error));
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw error;
}
}
/**
* Write a single Trigger control record to `.out`. The record carries a
* `trigger-control` header valued with `subtype` plus any sibling
* `extraHeaders`; the body is empty. Control records are filtered out of
* the consumer-facing chunk stream by the SDK transport — readers route
* them via the `onControl` callback instead.
*
* The returned `lastEventId` is the S2 seq_num of the written record,
* useful for trim chains (e.g. trim back to the previous turn-complete).
*/
async writeControl(
subtype: string,
extraHeaders?: ReadonlyArray<readonly [string, string]>
): Promise<StreamWriteResult> {
const apiClient = apiClientManager.clientOrThrow();
return writeSessionControlRecord(apiClient, this.sessionId, "out", subtype, extraHeaders);
}
/**
* Append an S2 `trim` command record to `.out`. Records with seq_num
* less than `earliestSeqNum` are eventually removed from the stream.
*
* Idempotent and monotonic at S2's layer (`max(existing, min(provided,
* current_tail))`) — backward trims are silently no-ops for deletion
* but still consume a seq_num. Used by `chat.agent`'s turn loop to
* keep `session.out` bounded to roughly one turn at steady state.
*/
async trimTo(earliestSeqNum: number): Promise<void> {
const apiClient = apiClientManager.clientOrThrow();
await trimSessionStream(apiClient, this.sessionId, earliestSeqNum);
}
}
/**
* The `.in` side of a Session's bidirectional channel pair. Mirrors
* {@link streams.input} — consumer-side primitives for the task
* (`on`/`once`/`peek`/`wait`/`waitWithIdleTimeout`) plus `send` for
* external clients. Keyed on the session rather than the run so a
* conversation can survive across run boundaries.
*/
export class SessionInputChannel {
constructor(public readonly sessionId: string) {}
/**
* Send a single record to the channel. Called by external clients
* (browser, server action, another task) producing input for the run.
* Matches {@link streams.input.send} but session-scoped — the session
* is the address, no `runId` required.
*/
async send(value: unknown, requestOptions?: ApiRequestOptions): Promise<void> {
const apiClient = apiClientManager.clientOrThrow();
const body = typeof value === "string" ? value : JSON.stringify(value);
const $requestOptions = mergeRequestOptions(
{
tracer,
name: `sessions.open(${this.sessionId}).in.send()`,
icon: "sessions",
attributes: sessionAttributes(this.sessionId, { io: "in" }),
},
requestOptions
);
await apiClient.appendToSessionStream(this.sessionId, "in", body, $requestOptions);
}
/**
* Register a handler that fires for every record landing on `.in`.
* Buffered records are offered to the handler on attach, and handlers
* are cleaned up automatically when the task run completes. Returns
* `{ off }` to unsubscribe early.
*
* A handler that synchronously returns `true` CONSUMES the record: it
* won't be buffered for a later `once()` and won't be re-delivered on a
* future `on()` attach. Plain observers should return nothing.
*/
on<T = unknown>(handler: (data: T) => void | boolean | Promise<void>): { off: () => void } {
return sessionStreams.on(
this.sessionId,
"in",
handler as (data: unknown) => void | boolean | Promise<void>
);
}
/**
* Wait for the next record on `.in` without suspending the run.
* Returns `{ ok: true, output }` on arrival or `{ ok: false, error }`
* when the timeout fires. Chain `.unwrap()` to get the data directly.
*/
once<T = unknown>(options?: InputStreamOnceOptions): InputStreamOncePromise<T> {
const ctx = taskContext.ctx;
const runId = ctx?.run.id;
const innerPromise = sessionStreams.once(this.sessionId, "in", options);
return new InputStreamOncePromise<T>((resolve, reject) => {
tracer
.startActiveSpan(
options?.spanName ?? `sessions.open(${this.sessionId}).in.once()`,
async () => {
const result = await innerPromise;
resolve(result as InputStreamOnceResult<T>);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "sessions",
[SemanticInternalAttributes.ENTITY_TYPE]: "session-stream",
...(runId
? { [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${this.sessionId}:in` }
: {}),
session: this.sessionId,
io: "in",
...accessoryAttributes({
items: [{ text: `${this.sessionId}.in`, variant: "normal" }],
style: "codepath",
}),
},
}
)
.catch(reject);
});
}
/** Non-blocking peek at the head of the `.in` buffer. */
peek<T = unknown>(): T | undefined {
return sessionStreams.peek(this.sessionId, "in") as T | undefined;
}
/**
* The highest S2 sequence number of any record this channel has
* delivered to a `once()` / `wait()` consumer (or had shifted off its
* buffer into one). Distinct from "last received" — buffered-but-not-
* yet-consumed records don't count.
*
* Used by `chat.agent` to persist the `.in` resume cursor on each
* `turn-complete` control record, so the next worker boot can subscribe
* past already-processed user messages.
*/
lastDispatchedSeqNum(): number | undefined {
return sessionStreams.lastDispatchedSeqNum(this.sessionId, "in");
}
/**
* Suspend the current run until the next record arrives on `.in`.
* Unlike {@link once}, `wait()` frees compute while blocked — the
* run-engine waitpoint holds the run until the session append handler
* fires it. Only callable from inside `task.run()`.
*/
wait<T = unknown>(options?: InputStreamWaitOptions): ManualWaitpointPromise<T> {
return new ManualWaitpointPromise<T>(async (resolve, reject) => {
try {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("session.in.wait() can only be used from inside a task.run()");
}
const apiClient = apiClientManager.clientOrThrow();
const response = await apiClient.createSessionStreamWaitpoint(ctx.run.id, {
session: this.sessionId,
io: "in",
timeout: options?.timeout,
idempotencyKey: options?.idempotencyKey,
idempotencyKeyTTL: options?.idempotencyKeyTTL,
tags: options?.tags,
lastSeqNum: sessionStreams.lastSeqNum(this.sessionId, "in"),
});
const result = await tracer.startActiveSpan(
options?.spanName ?? `sessions.open(${this.sessionId}).in.wait()`,
async (span) => {
const waitResponse = await apiClient.waitForWaitpointToken({
runFriendlyId: ctx.run.id,
waitpointFriendlyId: response.waitpointId,
});
if (!waitResponse.success) {
throw new Error("Failed to block on session stream waitpoint");
}
// Drop the SSE tail + buffer before suspending so the record
// delivered via the waitpoint path isn't re-buffered on resume.
sessionStreams.disconnectStream(this.sessionId, "in");
const waitResult = await runtime.waitUntil(response.waitpointId);
const data =
waitResult.output !== undefined
? await conditionallyImportAndParsePacket(
{
data: waitResult.output,
dataType: waitResult.outputType ?? "application/json",
},
apiClient
)
: undefined;
if (waitResult.ok) {
// Advance both cursors past the record consumed via the
// waitpoint: the seq counter so the SSE tail doesn't replay
// it, and the consume cursor so turn-completes don't stamp a
// stale `session-in-event-id`.
const prevSeq = sessionStreams.lastSeqNum(this.sessionId, "in");
const nextSeq = (prevSeq ?? -1) + 1;
sessionStreams.setLastSeqNum(this.sessionId, "in", nextSeq);
sessionStreams.setLastDispatchedSeqNum(this.sessionId, "in", nextSeq);
return { ok: true as const, output: data as T };
} else {
const error = new WaitpointTimeoutError(data?.message ?? "Timed out");
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
return { ok: false as const, error };
}
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: response.waitpointId,
session: this.sessionId,
io: "in",
...accessoryAttributes({
items: [{ text: `${this.sessionId}.in`, variant: "normal" }],
style: "codepath",
}),
},
}
);
resolve(result);
} catch (error) {
reject(error);
}
});
}
/**
* Wait for a record with an idle-then-suspend strategy. Keeps the run
* active (using compute) for `idleTimeoutInSeconds`, then suspends via
* {@link wait} if nothing arrives. If a record arrives during the idle
* phase the run responds without suspending.
*/
async waitWithIdleTimeout<T = unknown>(
options: InputStreamWaitWithIdleTimeoutOptions
): Promise<{ ok: true; output: T } | { ok: false; error?: Error }> {
// eslint-disable-next-line no-this-alias
const self = this;
const spanName =
options.spanName ?? `sessions.open(${this.sessionId}).in.waitWithIdleTimeout()`;
return tracer.startActiveSpan(
spanName,
async (span) => {
if (options.idleTimeoutInSeconds > 0) {
const warm = await sessionStreams.once(self.sessionId, "in", {
timeoutMs: options.idleTimeoutInSeconds * 1000,
});
if (warm.ok) {
span.setAttribute("wait.resolved", "idle");
return { ok: true as const, output: warm.output as T };
}
}
if (options.skipSuspend) {
// Match the cold-phase `self.wait()` result shape below so any
// caller that does `throw result.error` gets a real error
// instead of `undefined`.
span.setAttribute("wait.resolved", "skipped");
return {
ok: false as const,
error: new WaitpointTimeoutError("Idle timeout elapsed and skipSuspend is set"),
};
}
if (options.onSuspend) {
await options.onSuspend();
}
span.setAttribute("wait.resolved", "suspended");
const waitResult = await self.wait<T>({
timeout: options.timeout,
spanName: "suspended",
});
if (waitResult.ok && options.onResume) {
await options.onResume();
}
return waitResult;
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "sessions",
session: self.sessionId,
io: "in",
...accessoryAttributes({
items: [{ text: `${self.sessionId}.in`, variant: "normal" }],
style: "codepath",
}),
},
}
);
}
}
export type SessionSubscribeOptions<T = unknown> = {
signal?: AbortSignal;
lastEventId?: string | number;
/** Timeout in seconds for the underlying long-poll (max 600). */
timeoutInSeconds?: number;
/** Called for each SSE event with the full event metadata (id, timestamp). */
onPart?: (part: { id: string; chunk: T; timestamp: number }) => void;
/**
* Called when a `trigger-control` record arrives on the stream (e.g.
* `turn-complete`, `upgrade-required`). Control records are filtered
* out of the consumer chunk stream — handle them here. See
* `docs/ai-chat/client-protocol.mdx` for the wire shape.
*/
onControl?: (event: ControlEvent) => void;
/** Called when the server signals end-of-stream. */
onComplete?: () => void;
/** Called on unrecoverable errors after the retry budget is exhausted. */
onError?: (error: Error) => void;
};
// ─── helpers ────────────────────────────────────────────────────────
function sessionAttributes(id: string, extra?: Record<string, string | number | boolean>) {
return {
session: id,
...(extra ?? {}),
...accessoryAttributes({
items: [{ text: id, variant: "normal" }],
style: "codepath",
}),
};
}
function toAttr(value: string | string[]): string {
return Array.isArray(value) ? value.join(",") : value;
}
+270
View File
@@ -0,0 +1,270 @@
import { ApiClient } from "@trigger.dev/core/v3";
import { describe, it, expect } from "vitest";
import { offloadBatchItemPayloads, readableStreamToAsyncIterable } from "./shared.js";
describe("offloadBatchItemPayloads", () => {
// A real client is required for conditionallyExportPacket's truthy check; small payloads
// short-circuit before any network call, so this never actually reaches the server.
const apiClient = new ApiClient("http://localhost:3030", "tr_dev_test");
it("returns an empty array unchanged", async () => {
expect(await offloadBatchItemPayloads([], apiClient)).toEqual([]);
});
it("passes small payloads through and records their pre-offload byte size", async () => {
const payload = JSON.stringify({ hello: "world" });
const result = await offloadBatchItemPayloads(
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
apiClient
);
const item = result[0]!;
expect(item.payload).toBe(payload);
expect(item.options?.payloadType).toBe("application/json");
expect(item.options?.payloadSize).toBe(Buffer.byteLength(payload, "utf8"));
});
it("measures multi-byte payloads by byte length, not character count", async () => {
const payload = "€€€"; // 3 chars, 9 bytes in UTF-8
const result = await offloadBatchItemPayloads(
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
apiClient
);
expect(result[0]!.options?.payloadSize).toBe(9);
});
it("leaves an already-offloaded (application/store) item untouched", async () => {
const item = {
index: 0,
task: "my-task",
payload: "trigger/my-task/123/payload.json",
options: { payloadType: "application/store" },
};
const result = await offloadBatchItemPayloads([item], apiClient);
expect(result[0]).toEqual(item);
});
it("leaves items without a string payload untouched", async () => {
const item = { index: 0, task: "my-task", options: {} };
const result = await offloadBatchItemPayloads([item], apiClient);
expect(result[0]).toEqual(item);
});
});
describe("readableStreamToAsyncIterable", () => {
it("yields all values from the stream", async () => {
const values = [1, 2, 3, 4, 5];
const stream = new ReadableStream<number>({
start(controller) {
for (const value of values) {
controller.enqueue(value);
}
controller.close();
},
});
const result: number[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
}
expect(result).toEqual(values);
});
it("cancels the stream when consumer breaks early", async () => {
let cancelCalled = false;
const stream = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
controller.enqueue(2);
controller.enqueue(3);
controller.enqueue(4);
controller.enqueue(5);
controller.close();
},
cancel() {
cancelCalled = true;
},
});
const result: number[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
if (value === 2) {
break; // Early termination
}
}
expect(result).toEqual([1, 2]);
expect(cancelCalled).toBe(true);
});
it("cancels the stream when consumer throws an error", async () => {
let cancelCalled = false;
const stream = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
controller.enqueue(2);
controller.enqueue(3);
controller.close();
},
cancel() {
cancelCalled = true;
},
});
const result: number[] = [];
const testError = new Error("Test error");
await expect(async () => {
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
if (value === 2) {
throw testError;
}
}
}).rejects.toThrow(testError);
expect(result).toEqual([1, 2]);
expect(cancelCalled).toBe(true);
});
it("handles stream that produces values asynchronously", async () => {
const values = ["a", "b", "c"];
let index = 0;
const stream = new ReadableStream<string>({
async pull(controller) {
if (index < values.length) {
// Simulate async data production
await new Promise((resolve) => setTimeout(resolve, 1));
controller.enqueue(values[index]!);
index++;
} else {
controller.close();
}
},
});
const result: string[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
}
expect(result).toEqual(values);
});
it("cancels async stream when consumer breaks early", async () => {
let cancelCalled = false;
let producedCount = 0;
const stream = new ReadableStream<number>({
async pull(controller) {
// Simulate async data production
await new Promise((resolve) => setTimeout(resolve, 1));
producedCount++;
controller.enqueue(producedCount);
// Never close - infinite stream
},
cancel() {
cancelCalled = true;
},
});
const result: number[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
if (value >= 3) {
break;
}
}
expect(result).toEqual([1, 2, 3]);
expect(cancelCalled).toBe(true);
});
it("does not throw when cancelling an already-closed stream", async () => {
const stream = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
controller.close();
},
});
// Normal iteration should complete without errors
const result: number[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
}
expect(result).toEqual([1]);
});
it("does not throw when cancelling an errored stream", async () => {
const streamError = new Error("Stream error");
let errorIndex = 0;
const stream = new ReadableStream<number>({
pull(controller) {
errorIndex++;
if (errorIndex <= 2) {
controller.enqueue(errorIndex);
} else {
controller.error(streamError);
}
},
});
const result: number[] = [];
// The stream error should propagate
await expect(async () => {
for await (const value of readableStreamToAsyncIterable(stream)) {
result.push(value);
}
}).rejects.toThrow(streamError);
// We should have gotten the values before the error
expect(result).toEqual([1, 2]);
});
it("signals upstream producer to stop via cancel", async () => {
const producedValues: number[] = [];
let isProducing = true;
const stream = new ReadableStream<number>({
async pull(controller) {
if (!isProducing) return;
await new Promise((resolve) => setTimeout(resolve, 5));
const value = producedValues.length + 1;
producedValues.push(value);
controller.enqueue(value);
},
cancel() {
isProducing = false;
},
});
const consumed: number[] = [];
for await (const value of readableStreamToAsyncIterable(stream)) {
consumed.push(value);
if (value >= 2) {
break;
}
}
// Wait a bit to ensure no more values are produced
await new Promise((resolve) => setTimeout(resolve, 20));
expect(consumed).toEqual([1, 2]);
// Producer should have stopped after cancel
expect(isProducing).toBe(false);
// No more values should have been produced after breaking
expect(producedValues.length).toBeLessThanOrEqual(3);
});
});
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { resourceCatalog } from "@trigger.dev/core/v3";
/**
* Parsed `SKILL.md` frontmatter. Only `name` + `description` are required;
* additional keys are preserved but untyped.
*/
export type SkillFrontmatter = {
name: string;
description: string;
[key: string]: unknown;
};
/**
* A resolved skill ready to hand to `chat.skills.set()`. Includes the parsed
* SKILL.md content plus the on-disk path to the bundled skill folder.
*/
export type ResolvedSkill = {
id: string;
/** Skill version — `"local"` in Phase 1 until backend-managed overrides land. */
version: number | "local";
/** Labels applied to this version — empty in Phase 1. */
labels: string[];
/** Full raw `SKILL.md` content (with frontmatter). */
skillMd: string;
/** Parsed frontmatter fields. */
frontmatter: SkillFrontmatter;
/** Body of SKILL.md with the frontmatter block stripped. */
body: string;
/** Absolute path to the bundled skill folder (scripts, references, assets live here). */
path: string;
};
export type SkillOptions<TIdentifier extends string = string> = {
id: TIdentifier;
/** Path to the skill source folder, relative to the project root. */
path: string;
};
export type SkillHandle<TIdentifier extends string = string> = {
id: TIdentifier;
/**
* Read the bundled `SKILL.md` from disk and return the resolved skill.
*
* This is the Phase 1 path — backend-managed overrides are not available
* yet. Works locally (during `trigger dev`) and in the deploy image.
*/
local(): Promise<ResolvedSkill>;
/**
* Resolve the skill against the dashboard (current/override version).
*
* Not available in Phase 1 — throws. Use `local()` until backend-managed
* skills ship.
*/
resolve(): Promise<ResolvedSkill>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnySkillHandle = SkillHandle<string>;
/** Extract the id literal type from a SkillHandle. */
export type SkillIdentifier<T extends AnySkillHandle> =
T extends SkillHandle<infer TId> ? TId : string;
/**
* Bundled skills are copied to `${cwd}/.trigger/skills/{id}/` by the CLI at
* build time. At runtime the same layout holds for both `trigger dev` (cwd
* = dev output dir) and deploy (cwd = /app).
*/
function bundledSkillPath(id: string): string {
return path.resolve(process.cwd(), ".trigger", "skills", id);
}
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/;
/**
* Parse a minimal YAML-subset frontmatter block. We only support top-level
* string keys like `name: foo` and `description: bar`. Enough for SKILL.md
* frontmatter without pulling in a YAML dep.
*/
export function parseFrontmatter(content: string): {
frontmatter: SkillFrontmatter;
body: string;
} {
const match = content.match(FRONTMATTER_RE);
if (!match || !match[1]) {
throw new Error(
"Skill: SKILL.md is missing a frontmatter block. " +
"Expected `---\\nname: ...\\ndescription: ...\\n---` at the top of the file."
);
}
const raw = match[1];
const frontmatter: Record<string, unknown> = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf(":");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
// Strip surrounding quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (key) frontmatter[key] = value;
}
if (typeof frontmatter.name !== "string" || !frontmatter.name) {
throw new Error("Skill: SKILL.md frontmatter is missing required `name` field.");
}
if (typeof frontmatter.description !== "string" || !frontmatter.description) {
throw new Error("Skill: SKILL.md frontmatter is missing required `description` field.");
}
const body = content.slice(match[0].length);
return { frontmatter: frontmatter as SkillFrontmatter, body };
}
async function loadLocal(id: string): Promise<ResolvedSkill> {
const skillPath = bundledSkillPath(id);
const skillMdPath = path.join(skillPath, "SKILL.md");
let skillMd: string;
try {
skillMd = await fs.readFile(skillMdPath, "utf8");
} catch (err) {
throw new Error(
`Skill "${id}": could not read SKILL.md at ${skillMdPath}. ` +
`Skills must be bundled into .trigger/skills/{id}/ — this usually means ` +
`the CLI build step didn't run, or the skill wasn't registered via ai.defineSkill. ` +
`Underlying error: ${(err as Error).message}`
);
}
const { frontmatter, body } = parseFrontmatter(skillMd);
return {
id,
version: "local",
labels: [],
skillMd,
frontmatter,
body,
path: skillPath,
};
}
/**
* Define an agent skill — a developer-authored folder with a `SKILL.md` file
* plus optional `scripts/`, `references/`, and `assets/` subfolders. Registers
* the skill with the resource catalog so the Trigger.dev CLI can bundle it
* into the deploy image automatically (no build extension needed).
*
* Call `.local()` on the returned handle to load the bundled SKILL.md at
* runtime and use it with `chat.skills.set()`.
*
* @example
* ```ts
* // trigger/skills/pdf-processing/SKILL.md
* // trigger/skills/pdf-processing/scripts/extract.py
* import { ai } from "@trigger.dev/sdk";
*
* export const pdfSkill = ai.defineSkill({
* id: "pdf-processing",
* path: "./skills/pdf-processing",
* });
*
* export const agent = chat.agent({
* id: "docs",
* onChatStart: async () => {
* chat.skills.set([await pdfSkill.local()]);
* },
* run: async ({ messages, signal }) => {
* return streamText({
* model: openai("gpt-4o"),
* messages,
* abortSignal: signal,
* ...chat.toStreamTextOptions(),
* });
* },
* });
* ```
*/
export function defineSkill<TIdentifier extends string>(
options: SkillOptions<TIdentifier>
): SkillHandle<TIdentifier> {
resourceCatalog.registerSkillMetadata({
id: options.id,
sourcePath: options.path,
});
return {
id: options.id,
async local() {
return loadLocal(options.id);
},
async resolve() {
throw new Error(
`Skill "${options.id}": resolve() is not available yet — backend-managed ` +
`skills ship in Phase 2. Use skill.local() instead.`
);
},
};
}
+9
View File
@@ -0,0 +1,9 @@
export { defineSkill as define } from "./skill.js";
export type {
AnySkillHandle,
ResolvedSkill,
SkillFrontmatter,
SkillHandle,
SkillIdentifier,
SkillOptions,
} from "./skill.js";
@@ -0,0 +1,65 @@
import type * as Core from "@trigger.dev/core/v3";
import { realtimeStreams } from "@trigger.dev/core/v3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { streams } from "./streams.js";
vi.mock("@trigger.dev/core/v3", async (importOriginal) => {
const original = await importOriginal<typeof Core>();
return {
...original,
taskContext: {
ctx: {
run: {
id: "run_123",
// parentTaskRunId and rootTaskRunId are undefined for root tasks
},
},
},
realtimeStreams: {
pipe: vi.fn().mockReturnValue({
wait: () => Promise.resolve(),
stream: new ReadableStream(),
}),
},
};
});
describe("streams.pipe consistency", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should not throw and should use self runId when target is 'root' in a root task", async () => {
const mockStream = new ReadableStream();
// This should not throw anymore
const { waitUntilComplete: _waitUntilComplete } = streams.pipe("test-key", mockStream, {
target: "root",
});
expect(realtimeStreams.pipe).toHaveBeenCalledWith(
"test-key",
mockStream,
expect.objectContaining({
target: "run_123",
})
);
});
it("should not throw and should use self runId when target is 'parent' in a root task", async () => {
const mockStream = new ReadableStream();
// This should not throw anymore
const { waitUntilComplete: _waitUntilComplete } = streams.pipe("test-key", mockStream, {
target: "parent",
});
expect(realtimeStreams.pipe).toHaveBeenCalledWith(
"test-key",
mockStream,
expect.objectContaining({
target: "run_123",
})
);
});
});
+994
View File
@@ -0,0 +1,994 @@
import {
type AsyncIterableStream,
type WriterStreamOptions,
type PipeStreamOptions,
type PipeStreamResult,
type ReadStreamOptions,
type AppendStreamOptions,
type RealtimeDefinedStream,
type InferStreamType,
realtimeStreams,
inputStreams,
taskContext,
type RealtimeStreamOperationOptions,
mergeRequestOptions,
accessoryAttributes,
SemanticInternalAttributes,
apiClientManager,
ManualWaitpointPromise,
WaitpointTimeoutError,
runtime,
logger,
type RealtimeDefinedInputStream,
InputStreamOncePromise,
type InputStreamOnceResult,
type InferInputStreamType,
} from "@trigger.dev/core/v3";
import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
import { tracer } from "./tracer.js";
import { locals } from "./locals.js";
import { SpanStatusCode } from "@opentelemetry/api";
const DEFAULT_STREAM_KEY = "default";
// `chat.agent` sets this once at the top of every run via
// `markChatAgentRunForStreamsWarning`. The flag lives on the run's
// AsyncLocalStorage frame, so it naturally resets between runs and stays
// invisible to subtasks (where `streams.*` is a normal API).
const inChatAgentRunKey = locals.create<boolean>("streams.inChatAgentRun");
// Once-per-run dedup. `streams.*` callers inside a chat.agent run get the
// nudge on the first call and silence afterwards; a single tight loop
// won't spam the logs.
const chatAgentStreamsWarnedKey = locals.create<boolean>("streams.chatAgentWarned");
/**
* Marks the current run as a `chat.agent` run so subsequent `streams.pipe` /
* `streams.append` / `streams.read` calls can warn the user that they're
* writing to a run-scoped stream rather than the chat's `session.out`.
*
* Called from inside the `chat.agent` task wrapper at the top of every run.
*
* @internal
*/
export function markChatAgentRunForStreamsWarning(): void {
locals.set(inChatAgentRunKey, true);
}
function warnIfChatAgentStreamsMisuse(method: "pipe" | "append" | "read" | "writer"): void {
if (!locals.get(inChatAgentRunKey)) return;
if (locals.get(chatAgentStreamsWarnedKey)) return;
locals.set(chatAgentStreamsWarnedKey, true);
logger.warn(
`streams.${method}() was called inside a chat.agent run. This writes to a run-scoped realtime stream and is NOT visible on the chat session, so the chat UI will not see these chunks. For chat output use chat.response.write() or chat.stream.* instead. See https://trigger.dev/docs/ai-chat/patterns/large-payloads. (Logged once per run; subsequent streams.${method}() calls in this run are silent.)`
);
}
/**
* Pipes data to a realtime stream using the default stream key (`"default"`).
*
* This is a convenience overload that allows you to pipe data without specifying a stream key.
* The stream will be created/accessed with the key `"default"`.
*
* @template T - The type of data chunks in the stream
* @param value - The stream of data to pipe from. Can be an `AsyncIterable<T>` or `ReadableStream<T>`.
* @param options - Optional configuration for the stream operation
* @returns A promise that resolves to an object containing:
* - `stream`: The original stream (can be consumed in your task)
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk";
*
* // Stream OpenAI completion chunks to the default stream
* const completion = await openai.chat.completions.create({
* model: "gpt-4",
* messages: [{ role: "user", content: "Hello" }],
* stream: true,
* });
*
* const { waitUntilComplete } = await streams.pipe(completion);
*
* // Process the stream locally
* for await (const chunk of completion) {
* console.log(chunk);
* }
*
* // Or alternatievely wait for all chunks to be sent to the realtime stream
* await waitUntilComplete();
* ```
*/
function pipe<T>(
value: AsyncIterable<T> | ReadableStream<T>,
options?: PipeStreamOptions
): PipeStreamResult<T>;
/**
* Pipes data to a realtime stream with a specific stream key.
*
* Use this overload when you want to use a custom stream key instead of the default.
*
* @template T - The type of data chunks in the stream
* @param key - The unique identifier for this stream. If multiple streams use the same key,
* they will be merged into a single stream. Defaults to `"default"` if not provided.
* @param value - The stream of data to pipe from. Can be an `AsyncIterable<T>` or `ReadableStream<T>`.
* @param options - Optional configuration for the stream operation
* @returns A promise that resolves to an object containing:
* - `stream`: The original stream (can be consumed in your task)
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk";
*
* // Stream data to a specific stream key
* const myStream = createAsyncGenerator();
* const { waitUntilComplete } = await streams.pipe("my-custom-stream", myStream);
*
* // Process the stream locally
* for await (const chunk of myStream) {
* console.log(chunk);
* }
*
* // Wait for all chunks to be sent
* await waitUntilComplete();
* ```
*
* @example
* ```ts
* // Stream to a parent run
* await streams.pipe("output", myStream, {
* target: "parent",
* });
* ```
*/
function pipe<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
options?: PipeStreamOptions
): PipeStreamResult<T>;
function pipe<T>(
keyOrValue: string | AsyncIterable<T> | ReadableStream<T>,
valueOrOptions?: AsyncIterable<T> | ReadableStream<T> | PipeStreamOptions,
options?: PipeStreamOptions
): PipeStreamResult<T> {
// Handle overload: pipe(value, options?) or pipe(key, value, options?)
let key: string;
let value: AsyncIterable<T> | ReadableStream<T>;
let opts: PipeStreamOptions | undefined;
if (typeof keyOrValue === "string") {
// pipe(key, value, options?)
key = keyOrValue;
value = valueOrOptions as AsyncIterable<T> | ReadableStream<T>;
opts = options;
} else {
// pipe(value, options?)
key = DEFAULT_STREAM_KEY;
value = keyOrValue;
opts = valueOrOptions as PipeStreamOptions | undefined;
}
return pipeInternal(key, value, opts, opts?.spanName ?? "streams.pipe()");
}
/**
* Internal pipe implementation that allows customizing the span name.
* This is used by both the public `pipe` method and the `writer` method.
*/
function pipeInternal<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
opts: PipeStreamOptions | undefined,
spanName: string
): PipeStreamResult<T> {
warnIfChatAgentStreamsMisuse(spanName === "streams.writer()" ? "writer" : "pipe");
const runId = getRunIdForOptions(opts);
if (!runId) {
throw new Error(
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option or use this function from inside a task."
);
}
const span = tracer.startSpan(spanName, {
attributes: {
key,
runId,
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
[SemanticInternalAttributes.STYLE_ICON]: "streams",
...(opts?.collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}),
...accessoryAttributes({
items: [
{
text: key,
variant: "normal",
},
],
style: "codepath",
}),
},
});
const requestOptions = mergeRequestOptions({}, opts?.requestOptions);
try {
const instance = realtimeStreams.pipe(key, value, {
signal: opts?.signal,
target: runId,
requestOptions,
});
instance.wait().finally(() => {
span.end();
});
return {
stream: instance.stream,
waitUntilComplete: async () => {
return instance.wait();
},
};
} catch (error) {
// if the error is a signal abort error, we need to end the span but not record an exception
if (error instanceof Error && error.name === "AbortError") {
span.end();
throw error;
}
if (error instanceof Error || typeof error === "string") {
span.recordException(error);
} else {
span.recordException(String(error));
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw error;
}
}
/**
* Reads data from a realtime stream using the default stream key (`"default"`).
*
* This is a convenience overload that allows you to read from the default stream without
* specifying a stream key. The stream will be accessed with the key `"default"`.
*
* @template T - The type of data chunks in the stream
* @param runId - The unique identifier of the run to read the stream from
* @param options - Optional configuration for reading the stream
* @returns A promise that resolves to an `AsyncIterableStream<T>` that can be consumed
* using `for await...of` or as a `ReadableStream`.
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk/v3";
*
* // Read from the default stream
* const stream = await streams.read<string>(runId);
*
* for await (const chunk of stream) {
* console.log("Received chunk:", chunk);
* }
* ```
*
* @example
* ```ts
* // Read with custom timeout and starting position
* const stream = await streams.read<string>(runId, {
* timeoutInSeconds: 120,
* startIndex: 10, // Start from the 10th chunk
* });
* ```
*/
function read<T>(runId: string, options?: ReadStreamOptions): Promise<AsyncIterableStream<T>>;
/**
* Reads data from a realtime stream with a specific stream key.
*
* Use this overload when you want to read from a stream with a custom key.
*
* @template T - The type of data chunks in the stream
* @param runId - The unique identifier of the run to read the stream from
* @param key - The unique identifier of the stream to read from. Defaults to `"default"` if not provided.
* @param options - Optional configuration for reading the stream
* @returns A promise that resolves to an `AsyncIterableStream<T>` that can be consumed
* using `for await...of` or as a `ReadableStream`.
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk";
*
* // Read from a specific stream key
* const stream = await streams.read<string>(runId, "my-custom-stream");
*
* for await (const chunk of stream) {
* console.log("Received chunk:", chunk);
* }
* ```
*
* @example
* ```ts
* // Read with signal for cancellation
* const controller = new AbortController();
* const stream = await streams.read<string>(runId, "my-stream", {
* signal: controller.signal,
* timeoutInSeconds: 30,
* });
*
* // Cancel after 5 seconds
* setTimeout(() => controller.abort(), 5000);
* ```
*/
function read<T>(
runId: string,
key: string,
options?: ReadStreamOptions
): Promise<AsyncIterableStream<T>>;
async function read<T>(
runId: string,
keyOrOptions?: string | ReadStreamOptions,
options?: ReadStreamOptions
): Promise<AsyncIterableStream<T>> {
// Handle overload: read(runId, options?) or read(runId, key, options?)
let key: string;
let opts: ReadStreamOptions | undefined;
if (typeof keyOrOptions === "string") {
// read(runId, key, options?)
key = keyOrOptions;
opts = options;
} else {
// read(runId, options?)
key = DEFAULT_STREAM_KEY;
opts = keyOrOptions;
}
// Rename to readStream for consistency with existing code
return readStreamImpl(runId, key, opts);
}
async function readStreamImpl<T>(
runId: string,
key: string,
options?: ReadStreamOptions
): Promise<AsyncIterableStream<T>> {
warnIfChatAgentStreamsMisuse("read");
const apiClient = apiClientManager.clientOrThrow();
const span = tracer.startSpan("streams.read()", {
attributes: {
key,
runId,
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
[SemanticInternalAttributes.ENTITY_METADATA]: JSON.stringify({
startIndex: options?.startIndex,
}),
[SemanticInternalAttributes.STYLE_ICON]: "streams",
...accessoryAttributes({
items: [
{
text: key,
variant: "normal",
},
],
style: "codepath",
}),
},
});
return await apiClient.fetchStream(runId, key, {
signal: options?.signal,
timeoutInSeconds: options?.timeoutInSeconds ?? 60,
lastEventId: options?.startIndex ? (options.startIndex - 1).toString() : undefined,
onComplete: () => {
span.end();
},
onError: (error) => {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
},
});
}
function append<TPart extends BodyInit>(value: TPart, options?: AppendStreamOptions): Promise<void>;
function append<TPart extends BodyInit>(
key: string,
value: TPart,
options?: AppendStreamOptions
): Promise<void>;
function append<TPart extends BodyInit>(
keyOrValue: string | TPart,
valueOrOptions?: TPart | AppendStreamOptions,
options?: AppendStreamOptions
): Promise<void> {
if (typeof keyOrValue === "string" && typeof valueOrOptions === "string") {
return appendInternal(keyOrValue, valueOrOptions, options);
}
if (typeof keyOrValue === "string") {
if (isAppendStreamOptions(valueOrOptions)) {
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, valueOrOptions);
} else {
if (!valueOrOptions) {
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, options);
}
return appendInternal(keyOrValue, valueOrOptions, options);
}
} else {
if (isAppendStreamOptions(valueOrOptions)) {
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, valueOrOptions);
} else {
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, options);
}
}
}
async function appendInternal<TPart extends BodyInit>(
key: string,
part: TPart,
options?: AppendStreamOptions
): Promise<void> {
warnIfChatAgentStreamsMisuse("append");
const runId = getRunIdForOptions(options);
if (!runId) {
throw new Error(
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option or use this function from inside a task."
);
}
const span = tracer.startSpan("streams.append()", {
attributes: {
key,
runId,
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
[SemanticInternalAttributes.STYLE_ICON]: "streams",
...accessoryAttributes({
items: [
{
text: key,
variant: "normal",
},
],
style: "codepath",
}),
},
});
try {
await realtimeStreams.append(key, part, options);
span.end();
} catch (error) {
// if the error is a signal abort error, we need to end the span but not record an exception
if (error instanceof Error && error.name === "AbortError") {
span.end();
throw error;
}
if (error instanceof Error || typeof error === "string") {
span.recordException(error);
} else {
span.recordException(String(error));
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw error;
}
}
function isAppendStreamOptions(val: unknown): val is AppendStreamOptions {
return (
typeof val === "object" &&
val !== null &&
!Array.isArray(val) &&
(("target" in val && typeof val.target === "string") ||
("requestOptions" in val && typeof val.requestOptions === "object"))
);
}
/**
* Writes data to a realtime stream using the default stream key (`"default"`).
*
* This is a convenience overload that allows you to write to the default stream without
* specifying a stream key. The stream will be created/accessed with the key `"default"`.
*
* @template TPart - The type of data chunks in the stream
* @param options - The options for writing to the stream
* @returns A promise that resolves to an object containing:
* - `stream`: The original stream (can be consumed in your task)
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk";
*
* // Write to the default stream
* const { waitUntilComplete } = await streams.writer({
* execute: ({ write, merge }) => {
* write("chunk 1");
* write("chunk 2");
* write("chunk 3");
* },
* });
*
* // Wait for all chunks to be written
* await waitUntilComplete();
* ```
*
* @example
* ```ts
* // Write to a specific stream key
* const { waitUntilComplete } = await streams.writer("my-custom-stream", {
* execute: ({ write, merge }) => {
* write("chunk 1");
* write("chunk 2");
* write("chunk 3");
* },
* });
*
* // Wait for all chunks to be written
* await waitUntilComplete();
* ```
*
* @example
* ```ts
* // Write to a parent run
* await streams.writer("output", {
* execute: ({ write, merge }) => {
* write("chunk 1");
* write("chunk 2");
* write("chunk 3");
* },
* });
*
* // Wait for all chunks to be written
* await waitUntilComplete();
* ```
*
* @example
* ```ts
* // Write to a specific stream key
* await streams.writer("my-custom-stream", {
* execute: ({ write, merge }) => {
* write("chunk 1");
* write("chunk 2");
* write("chunk 3");
* },
* });
*
* // Wait for all chunks to be written
* await waitUntilComplete();
* ```
*/
function writer<TPart>(options: WriterStreamOptions<TPart>): PipeStreamResult<TPart>;
/**
* Writes data to a realtime stream with a specific stream key.
*
* @template TPart - The type of data chunks in the stream
* @param key - The unique identifier of the stream to write to. Defaults to `"default"` if not provided.
* @param options - The options for writing to the stream
* @returns A promise that resolves to an object containing:
* - `stream`: The original stream (can be consumed in your task)
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
*
* @example
* ```ts
* import { streams } from "@trigger.dev/sdk";
*
* // Write to a specific stream key
* const { waitUntilComplete } = await streams.writer("my-custom-stream", {
* execute: ({ write, merge }) => {
* write("chunk 1");
* write("chunk 2");
* write("chunk 3");
* },
* });
*
* // Wait for all chunks to be written
* await waitUntilComplete();
* ```
*/
function writer<TPart>(key: string, options: WriterStreamOptions<TPart>): PipeStreamResult<TPart>;
function writer<TPart>(
keyOrOptions: string | WriterStreamOptions<TPart>,
valueOrOptions?: WriterStreamOptions<TPart>
): PipeStreamResult<TPart> {
if (typeof keyOrOptions === "string") {
return writerInternal(keyOrOptions, valueOrOptions!);
}
return writerInternal(DEFAULT_STREAM_KEY, keyOrOptions);
}
function writerInternal<TPart>(key: string, options: WriterStreamOptions<TPart>) {
let controller!: ReadableStreamDefaultController<TPart>;
const ongoingStreamPromises: Promise<void>[] = [];
const stream = new ReadableStream({
start(controllerArg) {
controller = controllerArg;
},
});
function safeEnqueue(data: TPart) {
try {
controller.enqueue(data);
} catch (_error) {
// suppress errors when the stream has been closed
}
}
try {
const result = options.execute({
write(part) {
safeEnqueue(part);
},
merge(streamArg) {
ongoingStreamPromises.push(
(async () => {
const reader = streamArg.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
safeEnqueue(value);
}
})().catch((error) => {
console.error(error);
})
);
},
});
if (result) {
ongoingStreamPromises.push(
result.catch((error) => {
console.error(error);
})
);
}
} catch (error) {
console.error(error);
}
const waitForStreams: Promise<void> = new Promise((resolve, reject) => {
(async () => {
while (ongoingStreamPromises.length > 0) {
await ongoingStreamPromises.shift();
}
resolve();
})().catch(reject);
});
waitForStreams.finally(() => {
try {
controller.close();
} catch (_error) {
// suppress errors when the stream has been closed
}
});
return pipeInternal(key, stream, options, options.spanName ?? "streams.writer()");
}
export type RealtimeDefineStreamOptions = {
id: string;
};
function define<TPart>(opts: RealtimeDefineStreamOptions): RealtimeDefinedStream<TPart> {
return {
id: opts.id,
pipe(value, options) {
return pipe(opts.id, value, options);
},
read(runId, options) {
return read(runId, opts.id, options);
},
async append(value, options) {
// Use a single-write writer so objects are serialized the same way
// as stream.writer() — the raw append API sends BodyInit which
// doesn't serialize objects correctly for SSE consumers.
const { waitUntilComplete } = writer(opts.id, {
...options,
spanName: "streams.append()",
execute: ({ write }) => {
write(value);
},
});
await waitUntilComplete();
},
writer(options) {
return writer(opts.id, options);
},
};
}
export type { InferStreamType, InferInputStreamType };
/**
* Define an input stream that can receive typed data from external callers.
*
* Inside a task, use `.on()`, `.once()`, or `.peek()` to receive data.
* Outside a task (e.g., from your backend), use `.send(runId, data)` to send data.
*
* @template TData - The type of data this input stream receives
* @param opts - Options including a unique `id` for this input stream
*
* @example
* ```ts
* import { streams, task } from "@trigger.dev/sdk";
*
* const approval = streams.input<{ approved: boolean; reviewer: string }>({ id: "approval" });
*
* export const myTask = task({
* id: "my-task",
* run: async (payload) => {
* // Wait for the next approval
* const data = await approval.once().unwrap();
* console.log(data.approved, data.reviewer);
* },
* });
*
* // From your backend:
* // await approval.send(runId, { approved: true, reviewer: "alice" });
* ```
*/
function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
return {
id: opts.id,
on(handler) {
return inputStreams.on(opts.id, handler as (data: unknown) => void | Promise<void>);
},
once(options) {
const ctx = taskContext.ctx;
const runId = ctx?.run.id;
const innerPromise = inputStreams.once(opts.id, options);
return new InputStreamOncePromise<TData>((resolve, reject) => {
tracer
.startActiveSpan(
options?.spanName ?? `inputStream.once()`,
async () => {
const result = await innerPromise;
resolve(result as InputStreamOnceResult<TData>);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "streams",
[SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
...(runId ? { [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${opts.id}` } : {}),
streamId: opts.id,
...accessoryAttributes({
items: [{ text: opts.id, variant: "normal" }],
style: "codepath",
}),
},
}
)
.catch(reject);
});
},
peek() {
return inputStreams.peek(opts.id) as TData | undefined;
},
wait(options) {
return new ManualWaitpointPromise<TData>(async (resolve, reject) => {
try {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("inputStream.wait() can only be used from inside a task.run()");
}
const apiClient = apiClientManager.clientOrThrow();
// Create the waitpoint before the span so we have the entity ID upfront
const response = await apiClient.createInputStreamWaitpoint(ctx.run.id, {
streamId: opts.id,
timeout: options?.timeout,
idempotencyKey: options?.idempotencyKey,
idempotencyKeyTTL: options?.idempotencyKeyTTL,
tags: options?.tags,
lastSeqNum: inputStreams.lastSeqNum(opts.id),
});
const result = await tracer.startActiveSpan(
options?.spanName ?? `inputStream.wait()`,
async (span) => {
// 1. Block the run on the waitpoint
const waitResponse = await apiClient.waitForWaitpointToken({
runFriendlyId: ctx.run.id,
waitpointFriendlyId: response.waitpointId,
});
if (!waitResponse.success) {
throw new Error("Failed to block on input stream waitpoint");
}
// 2. Disconnect the SSE tail and clear the buffer before suspending.
// Without this, the tail stays alive during the suspension window and
// may buffer a copy of the same message that will be delivered via the
// waitpoint, causing a duplicate on resume.
inputStreams.disconnectStream(opts.id);
// 3. Suspend the task
const waitResult = await runtime.waitUntil(response.waitpointId);
// 4. Parse the output
const data =
waitResult.output !== undefined
? await conditionallyImportAndParsePacket(
{
data: waitResult.output,
dataType: waitResult.outputType ?? "application/json",
},
apiClient
)
: undefined;
if (waitResult.ok) {
// Advance the seq counter so the SSE tail doesn't replay
// the record that was consumed via the waitpoint path when
// it lazily reconnects on the next on()/once() call.
const prevSeq = inputStreams.lastSeqNum(opts.id);
inputStreams.setLastSeqNum(opts.id, (prevSeq ?? -1) + 1);
return { ok: true as const, output: data as TData };
} else {
const error = new WaitpointTimeoutError(data?.message ?? "Timed out");
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
return { ok: false as const, error };
}
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: response.waitpointId,
streamId: opts.id,
...accessoryAttributes({
items: [
{
text: opts.id,
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
resolve(result);
} catch (error) {
reject(error);
}
});
},
async waitWithIdleTimeout(options) {
// eslint-disable-next-line no-this-alias
const self = this;
const spanName = options.spanName ?? `inputStream.waitWithIdleTimeout()`;
return tracer.startActiveSpan(
spanName,
async (span) => {
// Idle phase: keep compute alive
if (options.idleTimeoutInSeconds > 0) {
const warm = await inputStreams.once(opts.id, {
timeoutMs: options.idleTimeoutInSeconds * 1000,
});
if (warm.ok) {
span.setAttribute("wait.resolved", "idle");
return { ok: true as const, output: warm.output as TData };
}
}
// Skip suspend if requested — return a real WaitpointTimeoutError
// so the result shape matches the cold-phase `self.wait()` path
// below. Callers that check `if (!result.ok)` work the same as
// before; callers that do `throw result.error` get a useful error
// instead of `undefined`.
if (options.skipSuspend) {
span.setAttribute("wait.resolved", "skipped");
return {
ok: false as const,
error: new WaitpointTimeoutError("Idle timeout elapsed and skipSuspend is set"),
};
}
// Fire onSuspend callback before entering cold phase
if (options.onSuspend) {
await options.onSuspend();
}
// Cold phase: suspend via .wait() — creates a child span
span.setAttribute("wait.resolved", "suspended");
const waitResult = await self.wait({
timeout: options.timeout,
spanName: "suspended",
});
// Fire onResume callback after successful resume
if (waitResult.ok && options.onResume) {
await options.onResume();
}
return waitResult;
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "streams",
streamId: opts.id,
...accessoryAttributes({
items: [{ text: opts.id, variant: "normal" }],
style: "codepath",
}),
},
}
);
},
async send(runId, data, options) {
return tracer.startActiveSpan(
`inputStream.send()`,
async () => {
const apiClient = apiClientManager.clientOrThrow();
await apiClient.sendInputStream(runId, opts.id, data, options?.requestOptions);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "streams",
[SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${opts.id}`,
streamId: opts.id,
runId,
...accessoryAttributes({
items: [{ text: opts.id, variant: "normal" }],
style: "codepath",
}),
},
}
);
},
};
}
export const streams = {
pipe,
read,
append,
writer,
define,
input,
};
function getRunIdForOptions(options?: RealtimeStreamOperationOptions): string | undefined {
if (options?.target) {
if (options.target === "parent") {
return taskContext.ctx?.run?.parentTaskRunId ?? taskContext.ctx?.run?.id;
}
if (options.target === "root") {
return taskContext.ctx?.run?.rootTaskRunId ?? taskContext.ctx?.run?.id;
}
if (options.target === "self") {
return taskContext.ctx?.run?.id;
}
return options.target;
}
return taskContext.ctx?.run?.id;
}
+61
View File
@@ -0,0 +1,61 @@
import type { ApiRequestOptions, RunTags } from "@trigger.dev/core/v3";
import {
UnprocessableEntityError,
accessoryAttributes,
apiClientManager,
logger,
mergeRequestOptions,
taskContext,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";
export const tags = {
add: addTags,
};
async function addTags(tags: RunTags, requestOptions?: ApiRequestOptions) {
const apiClient = apiClientManager.clientOrThrow();
const run = taskContext.ctx?.run;
if (!run) {
throw new Error(
"Can't set tags outside of a run. You can trigger a task and set tags in the options."
);
}
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "tags.set()",
icon: "tag",
attributes: {
...accessoryAttributes({
items: [
{
text: typeof tags === "string" ? tags : tags.join(", "),
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
try {
await apiClient.addTags(run.id, { tags }, $requestOptions);
} catch (error) {
if (error instanceof UnprocessableEntityError) {
logger.error(error.message, {
existingTags: run.tags,
newTags: tags,
});
return;
}
logger.error("Failed to set tags", { error });
throw error;
}
}
+115
View File
@@ -0,0 +1,115 @@
import {
onStart,
onStartAttempt,
onFailure,
onSuccess,
onComplete,
onWait,
onResume,
onHandleError,
onCatchError,
middleware,
onCancel,
} from "./hooks.js";
import {
batchTrigger,
batchTriggerAndWait,
createTask,
createSchemaTask,
createToolTask,
SubtaskUnwrapError,
trigger,
triggerAndWait,
triggerAndSubscribe,
} from "./shared.js";
export { SubtaskUnwrapError };
import type {
AnyTask,
BatchItem,
BatchResult,
BatchRunHandle,
Queue,
RunHandle,
Task,
TaskIdentifier,
TaskOptions,
TaskOutput,
TaskPayload,
TriggerOptions,
TaskRunResult,
TaskFromIdentifier,
TaskWithSchemaOptions,
TaskSchema,
TaskWithSchema,
TaskOptionsWithSchema,
} from "./shared.js";
export type {
AnyTask,
BatchItem,
BatchResult,
BatchRunHandle,
Queue,
RunHandle,
Task,
TaskIdentifier,
TaskOptions,
TaskOutput,
TaskPayload,
TriggerOptions,
TaskRunResult,
TaskFromIdentifier,
TaskWithSchemaOptions,
TaskSchema,
TaskWithSchema,
TaskOptionsWithSchema,
};
export type * from "./hooks.js";
/** Creates a task that can be triggered
* @param options - Task options
* @example
*
* ```ts
* import { task } from "@trigger.dev/sdk/v3";
*
* export const helloWorld = task({
id: "hello-world",
* run: async (payload: { url: string }) => {
* return { hello: "world" };
* },
* });
*
* ```
*
* @returns A task that can be triggered
*/
export const task = createTask;
export const schemaTask = createSchemaTask;
export const toolTask = createToolTask;
export const tasks = {
trigger,
batchTrigger,
triggerAndWait,
triggerAndSubscribe,
batchTriggerAndWait,
/** @deprecated Use onStartAttempt instead */
onStart,
onStartAttempt,
onFailure,
onSuccess,
onComplete,
onWait,
onResume,
onCancel,
/** @deprecated Use catchError instead */
handleError: onHandleError,
catchError: onCatchError,
middleware,
};
+23
View File
@@ -0,0 +1,23 @@
// Importing this module installs an in-memory resource catalog so that
// chat.agent() calls (which run at import time) register their task
// functions where the test harness can find them.
//
// Users should import `@trigger.dev/sdk/ai/test` BEFORE their agent
// modules so the registration side-effect runs first.
import "./setup-catalog.js";
export {
mockChatAgent,
type MockChatAgentOptions,
type MockChatAgentHarness,
type MockChatAgentTurn,
} from "./mock-chat-agent.js";
// Re-export the lower-level task context harness so consumers can build
// their own test helpers without adding a separate `@trigger.dev/core`
// dependency to their reference projects.
export {
runInMockTaskContext,
type MockTaskContextDrivers,
type MockTaskContextOptions,
} from "@trigger.dev/core/v3/test";
@@ -0,0 +1,789 @@
import type { UIMessage, UIMessageChunk } from "ai";
import { resourceCatalog } from "@trigger.dev/core/v3";
import type { LocalsKey } from "@trigger.dev/core/v3";
import { runInMockTaskContext, type MockTaskContextOptions } from "@trigger.dev/core/v3/test";
import { __setSessionOpenImplForTests, __setSessionStartImplForTests } from "../sessions.js";
import {
__setReadChatSnapshotImplForTests,
__setReplaySessionInTailImplForTests,
__setReplaySessionOutTailImplForTests,
__setWriteChatSnapshotImplForTests,
type ChatSnapshotV1,
} from "../ai.js";
import { createTestSessionHandle, type TestSessionOutState } from "./test-session-handle.js";
/** Pre-seed locals before the agent's `run()` starts. */
export type SetupLocals = (locals: {
set<T>(key: LocalsKey<T>, value: T): void;
}) => void | Promise<void>;
// The slim wire payload shape used by chat.agent tasks. Kept loose here so we
// don't import from the backend-only ai.ts module. At most ONE message per
// record — runtime rebuilds prior history from snapshot + replay at boot.
type ChatWirePayload = {
/** At most one message — singular under the slim wire. Set on submit-message. */
message?: UIMessage;
/** Bespoke escape hatch — only set on `trigger: "handover-prepare"`. */
headStartMessages?: UIMessage[];
chatId: string;
trigger:
| "submit-message"
| "regenerate-message"
| "preload"
| "close"
| "action"
| "handover-prepare";
messageId?: string;
metadata?: unknown;
action?: unknown;
continuation?: boolean;
previousRunId?: string;
idleTimeoutInSeconds?: number;
sessionId?: string;
};
/** A reference to a `chat.agent` task returned by `chat.agent({ id, ... })`. */
type ChatAgentHandle = { id: string };
/**
* Options for `mockChatAgent`.
*/
export type MockChatAgentOptions = {
/** The chat session id passed into every wire payload. Defaults to `"test-chat"`. */
chatId?: string;
/** Client-provided metadata (`clientData`) for the session. */
clientData?: unknown;
/** Task context overrides passed through to {@link runInMockTaskContext}. */
taskContext?: MockTaskContextOptions;
/**
* Whether to start the task in preload mode. Defaults to `true` so the
* first `sendMessage()` triggers the first turn via the preload path.
* Set to `false` to skip preload — the first `sendMessage()` starts turn 0 directly.
*
* Ignored when `mode: "handover-prepare"` is set.
*/
preload?: boolean;
/**
* Initial trigger the agent boots with. Defaults to `"preload"` (or
* `"submit-message"` when `preload: false`, or `"continuation"` when
* `continuation: true`).
*
* - `"preload"` — fresh chat preloaded via `transport.preload`. Fires
* `onPreload`, waits for the first message.
* - `"submit-message"` — fresh chat with the first message in the boot
* payload (the `chat.createStartSessionAction({ basePayload: { message } })`
* pattern). Goes straight to turn 0.
* - `"continuation"` — new run picking up an existing session after the
* prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`).
* Boots with `trigger` omitted and `continuation: true` — mirrors what
* the server's `ensureRunForSession` / `swapSessionRun` produces in
* production. The SDK enters its continuation-wait branch; `onPreload`
* and `onChatStart` do NOT fire on this run.
* - `"handover-prepare"` — drives the chat.handover wait branch; call
* `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal.
*/
mode?: "preload" | "submit-message" | "handover-prepare" | "continuation";
/**
* First-turn UIMessage history shipped on the BOOT payload. Only
* meaningful with `mode: "handover-prepare"` — mirrors the
* `chat.headStart` route handler's `basePayload.headStartMessages`.
*/
headStartMessages?: UIMessage[];
/**
* Pre-seed the snapshot the agent reads at run boot. The runtime's
* snapshot read is replaced with one that returns this snapshot
* (skipping the real S3 GET). Use to drive boot scenarios — fresh
* boot with prior history, OOM-retry boot with stale snapshot, etc.
* Pass `undefined` (the default) to start with no snapshot.
*
* See plan section B.3 for the boot orchestration spec.
*/
snapshot?: ChatSnapshotV1;
/**
* Set `payload.continuation = true` on the initial wire payload. Used
* to simulate a continuation-run boot (a new run picking up after a
* prior run on the same session ended via `chat.endRun`, waitpoint
* timeout, or `chat.requestUpgrade`).
*
* Setting this without specifying `mode` auto-selects `mode:
* "continuation"` — the SDK boot path enters its continuation-wait
* branch and waits silently on `session.in` for the first user
* message. `onPreload` and `onChatStart` do NOT fire on this run.
*
* Defaults to `false` (fresh run).
*/
continuation?: boolean;
/**
* Set `payload.previousRunId` on the initial wire payload. Forwarded
* to `onChatStart` / `onTurnStart` and used by the boot gate as a
* prior-state signal. Usually paired with `continuation: true`.
*/
previousRunId?: string;
/**
* Callback that runs **before** the agent's `run()` is invoked, with a
* `set` function for pre-seeding locals. Use this to inject server-side
* dependencies (database clients, service stubs) that the agent reads
* via `locals.get()` in its hooks.
*
* @example
* ```ts
* import { dbKey } from "./db";
*
* const harness = mockChatAgent(agent, {
* chatId: "test-1",
* setupLocals: (locals) => {
* locals.set(dbKey, testDb);
* },
* });
* ```
*/
setupLocals?: SetupLocals;
};
/**
* Result of a single turn, returned by driver methods like `sendMessage()`.
*/
export type MockChatAgentTurn = {
/** UIMessageChunks emitted during this turn (excludes control chunks like turn-complete). */
chunks: UIMessageChunk[];
/** All raw chunks including control chunks (turn-complete, upgrade-required, etc.). */
rawChunks: unknown[];
};
/**
* Harness returned by `mockChatAgent`. Drives a `chat.agent` task end-to-end
* without network or task runtime.
*/
export type MockChatAgentHarness = {
/** The chat session id used by this harness. */
readonly chatId: string;
/**
* Send a single user message (or tool-approval-responded assistant
* message) and wait for the next turn-complete. Returns the chunks
* produced during this turn.
*
* Slim wire: at most ONE message per send. The agent reconstructs prior
* history from snapshot + session.out replay at run boot.
*/
sendMessage(message: UIMessage): Promise<MockChatAgentTurn>;
/**
* Send a regenerate signal (no message body — slim wire). The agent
* trims trailing assistant messages from its in-memory accumulator and
* re-runs. Waits for turn-complete.
*/
sendRegenerate(): Promise<MockChatAgentTurn>;
/**
* Drive the head-start path: sends `trigger: "handover-prepare"` with
* `headStartMessages` carrying the first-turn UIMessage history. Used
* only at the very first turn before any snapshot exists. The route
* handler ships full UIMessage history through this path because the
* customer's HTTP endpoint isn't subject to the `/in/append` cap.
*/
sendHeadStart(args: { messages: UIMessage[] }): Promise<MockChatAgentTurn>;
/** Send a custom action and wait for the next turn-complete. */
sendAction(action: unknown): Promise<MockChatAgentTurn>;
/** Fire a stop signal. Does not wait for the turn — the task keeps running. */
sendStop(message?: string): Promise<void>;
/**
* Dispatch a `handover` signal — the agent picks up partial assistant
* messages and continues the turn. Only meaningful when the harness
* was started with `mode: "handover-prepare"`. Waits for turn-complete.
*
* `isFinal: false` (default) — agent runs `streamText` which executes
* any pending tool-calls (via the approval round) and resumes from
* step 2.
*
* `isFinal: true` — agent runs lifecycle hooks but skips `streamText`.
* The partial IS the response; `onTurnComplete` fires with it.
*/
sendHandover(args: {
partialAssistantMessage: unknown[];
isFinal?: boolean;
messageId?: string;
}): Promise<MockChatAgentTurn>;
/**
* Dispatch a `handover-skip` signal — the agent exits cleanly without
* firing turn hooks. Only meaningful when the harness was started
* with `mode: "handover-prepare"`. Awaits the run finishing.
*/
sendHandoverSkip(): Promise<void>;
/**
* Pre-seed the snapshot read for the next boot. The runtime's snapshot
* read returns this snapshot (skipping S3). Pass `undefined` to clear —
* the boot then sees no snapshot and falls through to replay-only.
*
* Effective on the next run boot only. Calling mid-turn is a no-op
* because the snapshot read happens once at run boot.
*/
seedSnapshot(snapshot: ChatSnapshotV1 | undefined): void;
/**
* Pre-seed `session.out` chunks for the next boot's replay. The runtime's
* `replaySessionOutTail` returns whatever the synthetic chunks reduce
* to. Pass `[]` to clear (boot replay returns no messages).
*
* Requires `__setReplaySessionOutTailImplForTests` exported from
* `ai.ts`. The harness throws a clear error at call time if that hook
* isn't available.
*/
seedSessionOutTail(chunks?: UIMessageChunk[]): void;
/**
* Pre-seed a trailing partial assistant message for the next boot's
* replay. The runtime's `replaySessionOutTail` returns this as the
* `partial` field (alongside whatever `seedSessionOutTail` reduces
* to). Use to simulate cancel-mid-stream: an assistant message whose
* `finish` chunk never arrived. Pass `undefined` to clear.
*
* Effective on the next run boot only.
*/
seedSessionOutPartial(partial: UIMessage | undefined): void;
/**
* Pre-seed user messages on the `session.in` tail for the next boot's
* replay. Each message is paired with a synthetic seq_num (`i + 1`).
* Used to simulate in-flight users the dead predecessor was supposed
* to process. Pass `[]` to clear.
*
* Effective on the next run boot only.
*/
seedSessionInTail(messages: UIMessage[]): void;
/**
* The most recently written snapshot, or `undefined` if no snapshot
* has been written yet. Updated each time `writeChatSnapshot` is
* invoked from the run loop's snapshot-write site (plan section B.6).
*/
getSnapshot(): ChatSnapshotV1 | undefined;
/**
* Close the chat session cleanly. Sends `trigger: "close"` and awaits the
* task's `run()` function returning. Call this at the end of every test
* (or use `await using`) so the background task isn't left dangling.
*/
close(): Promise<void>;
/** All UIMessageChunks emitted since the harness was created. */
readonly allChunks: UIMessageChunk[];
/** Every raw chunk (including control chunks) emitted since the harness was created. */
readonly allRawChunks: unknown[];
};
const CONTROL_CHUNK_TYPES = new Set(["trigger:turn-complete", "trigger:upgrade-required"]);
function isControlChunk(chunk: unknown): boolean {
if (typeof chunk !== "object" || chunk === null) return false;
const type = (chunk as { type?: string }).type;
return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type);
}
/**
* Create an offline test harness for a `chat.agent` task.
*
* The harness starts the agent's `run()` function in a mocked task context,
* waits in preload for the first message, then exposes driver methods for
* sending messages / actions / stop signals and awaiting turn completion.
*
* Users are responsible for mocking the language model themselves — use
* `MockLanguageModelV3` and `simulateReadableStream` from `ai/test` inside
* their agent's `run()` function (typically via DI through `clientData`).
*
* @example
* ```ts
* import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
* import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
* import { myAgent } from "./my-agent";
*
* test("says hello", async () => {
* const harness = mockChatAgent(myAgent, { chatId: "test-1" });
* try {
* const turn = await harness.sendMessage({
* id: "m1",
* role: "user",
* parts: [{ type: "text", text: "hi" }],
* });
* expect(turn.chunks).toContainEqual(
* expect.objectContaining({ type: "text-delta", delta: "hello" })
* );
* } finally {
* await harness.close();
* }
* });
* ```
*/
export function mockChatAgent(
agent: ChatAgentHandle,
options: MockChatAgentOptions = {}
): MockChatAgentHarness {
const chatId = options.chatId ?? "test-chat";
// The agent opens the session with `payload.sessionId ?? payload.chatId`.
// We pass no sessionId, so it falls back to chatId.
const sessionId = chatId;
// `continuation: true` without an explicit mode auto-selects "continuation"
// — the canonical shape for a continuation-run boot.
const mode: "preload" | "submit-message" | "handover-prepare" | "continuation" =
options.mode ??
(options.continuation === true
? "continuation"
: options.preload === false
? "submit-message"
: "preload");
const clientData = options.clientData;
const taskEntry = resourceCatalog.getTask(agent.id);
if (!taskEntry) {
throw new Error(
`mockChatAgent: no task registered with id "${agent.id}". ` +
`Import "@trigger.dev/sdk/ai/test" before your agent module so tasks register correctly.`
);
}
const runFn = taskEntry.fns.run;
// Session .out state: chunks + listener registry. Shared between the
// harness and the TestSessionOutputChannel installed via the open-override.
const sessionOutState: TestSessionOutState = {
chunks: [],
listeners: new Set(),
};
// Buffers that survive across harness method calls
const allRawChunks: unknown[] = [];
const allChunks: UIMessageChunk[] = [];
// Promise that resolves when the background task run() function returns.
let taskFinished!: Promise<void>;
let sendSessionInput!: (sessionId: string, data: unknown) => Promise<void>;
let closeSessionInput: ((sessionId: string) => void) | undefined;
let runSignal!: AbortController;
// A latch that resolves every time `trigger:turn-complete` appears on the chat stream.
// We use a shared pending promise and replace it after each completion.
let turnCompleteResolvers: Array<() => void> = [];
const waitForTurnComplete = () =>
new Promise<void>((resolve) => {
turnCompleteResolvers.push(resolve);
});
// Signal that the caller is ready to observe output
let harnessReadyResolve!: () => void;
const harnessReady = new Promise<void>((resolve) => {
harnessReadyResolve = resolve;
});
// ── Snapshot read/write override state ───────────────────────────────
// The runtime's snapshot read returns whatever `seededSnapshot` is at
// boot time. The runtime's snapshot write captures into
// `lastWrittenSnapshot` for harness consumers to assert via
// `getSnapshot()`. Installed below alongside the session overrides;
// cleared on close in the same finally block.
let seededSnapshot: ChatSnapshotV1 | undefined = options.snapshot;
let lastWrittenSnapshot: ChatSnapshotV1 | undefined;
let seededReplayChunks: UIMessageChunk[] = [];
let seededReplayPartial: UIMessage | undefined;
let seededSessionInMessages: UIMessage[] = [];
__setReadChatSnapshotImplForTests(<T extends UIMessage>(_id: string) => {
return seededSnapshot as ChatSnapshotV1<T> | undefined;
});
__setWriteChatSnapshotImplForTests(
<T extends UIMessage>(_id: string, snapshot: ChatSnapshotV1<T>) => {
lastWrittenSnapshot = snapshot as ChatSnapshotV1;
}
);
// Replay override: install a default that returns whatever
// `seededReplayChunks` reduces to. `mockChatAgent` doesn't model the
// settled-vs-partial split — seeded chunks always reduce to the
// `settled` array with `partial: undefined`. Recovery-specific
// tests can install their own override to seed a partial.
// Cleared in the same `finally` block as the other test overrides.
__setReplaySessionOutTailImplForTests(async () => {
const settled =
seededReplayChunks.length === 0
? []
: ((await reduceChunksToMessages(seededReplayChunks)) as unknown[]);
// For the mock harness, `partialRaw` is the same as `partial` — we
// don't model cleanupAbortedParts separately. Recovery tests that
// need a partialRaw distinct from partial install their own stub.
return {
settled,
partial: seededReplayPartial,
partialRaw: seededReplayPartial,
} as never;
});
// session.in tail override: each seeded UIMessage becomes a
// { message, metadata: undefined, seqNum: i+1 } entry. Mirrors the
// seq-num pattern from the out-tail stub so cursor-advance logic is
// exercised correctly. `metadata` is `undefined` for seeded users —
// the boot path falls back to `payload.metadata` for those.
__setReplaySessionInTailImplForTests(async () => {
return seededSessionInMessages.map((message, i) => ({
message,
metadata: undefined,
seqNum: i + 1,
})) as never;
});
// Install the session open override so `sessions.open(id)` returns a
// SessionHandle with an in-memory `.out` that captures writes. The
// `.in` channel routes record subscriptions (`on`/`once`/`peek`)
// through the `sessionStreams` global — the mock task context
// installs a `TestSessionStreamManager` there — and stubs `wait()`
// so the suspend path resolves cleanly on `runSignal.abort()` without
// touching the api client.
__setSessionOpenImplForTests((id) =>
createTestSessionHandle(id, sessionOutState, () => runSignal?.signal)
);
// Install the session start override so any test path that invokes
// `sessions.start()` (typically through a server action shim like
// `chat.createStartSessionAction`) becomes a no-op fixture instead of
// hitting a real API. Most chat.agent tests trigger the run directly
// via `sendPayloadAndWait` and never go through this path, but the
// stub keeps the API safe to call from inside tested code.
__setSessionStartImplForTests((body) => {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] sessions.start override:", body);
}
const fakeRunId = `run_test_${body.externalId ?? "anon"}`;
return {
id: `session_test_${body.externalId ?? "anon"}`,
externalId: body.externalId ?? null,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: body.triggerConfig,
currentRunId: fakeRunId,
runId: fakeRunId,
publicAccessToken: "tr_test_session_pat",
tags: body.tags ?? [],
metadata: (body.metadata ?? null) as Record<string, unknown> | null,
closedAt: null,
closedReason: null,
expiresAt: null,
createdAt: new Date(0),
updatedAt: new Date(0),
isCached: false,
};
});
taskFinished = runInMockTaskContext(async (drivers) => {
runSignal = new AbortController();
// For `mode: "continuation"`, omit `trigger` from the wire payload —
// mirrors what the server's `ensureRunForSession` / `swapSessionRun`
// produces (the continuation overrides clear `trigger` so the SDK
// boot path falls into the continuation-wait branch instead of
// re-firing the basePayload's stale first-run trigger). `continuation:
// true` is set unconditionally for this mode so the boot path's
// continuation-wait condition matches.
const isContinuationMode = mode === "continuation";
const initialPayload: ChatWirePayload = {
chatId,
...(isContinuationMode
? { trigger: undefined as never, continuation: true }
: { trigger: mode }),
metadata: clientData,
...(!isContinuationMode && options.continuation ? { continuation: true } : {}),
...(options.previousRunId ? { previousRunId: options.previousRunId } : {}),
...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}),
};
sendSessionInput = drivers.sessions.in.send;
closeSessionInput = drivers.sessions.in.close;
// Record every chunk written to session.out, detect turn-complete.
const listener = (chunk: unknown) => {
allRawChunks.push(chunk);
if (!isControlChunk(chunk)) {
allChunks.push(chunk as UIMessageChunk);
}
if (
typeof chunk === "object" &&
chunk !== null &&
(chunk as { type?: string }).type === "trigger:turn-complete"
) {
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
}
};
sessionOutState.listeners.add(listener);
const unsubscribe = () => sessionOutState.listeners.delete(listener);
if (options.setupLocals) {
await options.setupLocals({ set: drivers.locals.set });
}
harnessReadyResolve();
try {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] Starting runFn with payload:", initialPayload);
}
await runFn(initialPayload, {
ctx: drivers.ctx,
signal: runSignal.signal,
});
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] runFn returned");
}
} catch (err) {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] runFn threw:", err);
}
throw err;
} finally {
unsubscribe();
// Resolve any outstanding turn-complete waiters so callers don't hang
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
}
}, options.taskContext)
.catch((err) => {
// Propagate errors to pending turn waiters instead of dropping them
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
throw err;
})
.finally(() => {
// Always clear the test overrides, even if the task threw.
__setSessionOpenImplForTests(undefined);
__setSessionStartImplForTests(undefined);
__setReadChatSnapshotImplForTests(undefined);
__setWriteChatSnapshotImplForTests(undefined);
__setReplaySessionOutTailImplForTests(undefined);
__setReplaySessionInTailImplForTests(undefined);
});
const sendPayloadAndWait = async (payload: ChatWirePayload): Promise<MockChatAgentTurn> => {
await harnessReady;
const before = allRawChunks.length;
const turnComplete = waitForTurnComplete();
await sendSessionInput(sessionId, { kind: "message", payload });
await turnComplete;
const rawChunks = allRawChunks.slice(before);
const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[];
return { chunks, rawChunks };
};
const harness: MockChatAgentHarness = {
chatId,
async sendMessage(message) {
return sendPayloadAndWait({
message,
chatId,
trigger: "submit-message",
metadata: clientData,
});
},
async sendRegenerate() {
return sendPayloadAndWait({
chatId,
trigger: "regenerate-message",
metadata: clientData,
});
},
async sendHeadStart({ messages }) {
return sendPayloadAndWait({
headStartMessages: messages,
chatId,
trigger: "handover-prepare",
metadata: clientData,
});
},
async sendAction(action) {
return sendPayloadAndWait({
chatId,
trigger: "action",
action,
metadata: clientData,
});
},
async sendStop(message) {
await harnessReady;
await sendSessionInput(sessionId, { kind: "stop", message });
},
async sendHandover(args) {
await harnessReady;
const before = allRawChunks.length;
const turnComplete = waitForTurnComplete();
await sendSessionInput(sessionId, {
kind: "handover",
partialAssistantMessage: args.partialAssistantMessage,
messageId: args.messageId,
isFinal: args.isFinal ?? false,
});
await turnComplete;
const rawChunks = allRawChunks.slice(before);
const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[];
return { chunks, rawChunks };
},
async sendHandoverSkip() {
await harnessReady;
// No turn-complete on skip — the agent exits without firing hooks.
// Send the chunk and wait for the run to finish.
await sendSessionInput(sessionId, { kind: "handover-skip" });
await Promise.race([
taskFinished.catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
]);
},
seedSnapshot(snapshot) {
seededSnapshot = snapshot;
},
seedSessionOutTail(chunks) {
seededReplayChunks = chunks ?? [];
},
seedSessionOutPartial(partial) {
seededReplayPartial = partial;
},
seedSessionInTail(messages) {
seededSessionInMessages = messages;
},
getSnapshot() {
return lastWrittenSnapshot;
},
async close() {
await harnessReady;
// Send a close trigger wrapped as a `kind: "message"` ChatInputChunk.
// The turn loop checks for this after a successful turn and exits
// cleanly. On error-recovery paths the loop just loops back with
// the close payload, so we also close the session input below to
// unblock any pending once() waiters.
try {
await sendSessionInput(sessionId, {
kind: "message",
payload: {
chatId,
trigger: "close",
},
});
} catch {
// best-effort
}
// Resolve any pending once() waiters on the session input with a
// timeout error — that makes waitWithIdleTimeout return
// `{ ok: false }` and the turn loop exits cleanly.
closeSessionInput?.(sessionId);
// Also abort the run signal so anything downstream (streamText,
// deferred work) unwinds promptly.
runSignal?.abort("close");
// Wait for run() to return. The loop's error recovery path will
// see !next.ok and exit. Use a bounded wait so tests never hang.
await Promise.race([
taskFinished.catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
]);
},
get allChunks() {
return allChunks.slice();
},
get allRawChunks() {
return allRawChunks.slice();
},
};
return harness;
}
/**
* Reduce a synthetic UIMessageChunk[] sequence into the UIMessage[] that
* the runtime's `replaySessionOutTail` would produce. Splits chunks at
* `start` boundaries and feeds each segment through AI SDK's
* `readUIMessageStream`. The trailing un-finished segment goes through
* `cleanupAbortedParts`. Mirrors the production reducer used in
* `ai.ts:replaySessionOutTail`.
*/
async function reduceChunksToMessages(chunks: UIMessageChunk[]): Promise<UIMessage[]> {
if (chunks.length === 0) return [];
const aiModule = (await import("ai")) as {
readUIMessageStream?: (args: {
stream: ReadableStream<UIMessageChunk>;
}) => AsyncIterable<UIMessage>;
cleanupAbortedParts?: (msg: UIMessage) => UIMessage;
};
const readUIMessageStream = aiModule.readUIMessageStream;
const cleanupAbortedParts = aiModule.cleanupAbortedParts;
if (!readUIMessageStream) return [];
type Segment = { chunks: UIMessageChunk[]; closed: boolean };
const segments: Segment[] = [];
let current: Segment | undefined;
for (const chunk of chunks) {
if (chunk.type === "start") {
current = { chunks: [chunk], closed: false };
segments.push(current);
continue;
}
if (!current) {
current = { chunks: [], closed: false };
segments.push(current);
}
current.chunks.push(chunk);
if (chunk.type === "finish") {
current.closed = true;
current = undefined;
}
}
const out: UIMessage[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const isTrailing = i === segments.length - 1 && !seg.closed;
const segmentStream = new ReadableStream<UIMessageChunk>({
start(controller) {
for (const c of seg.chunks) controller.enqueue(c);
controller.close();
},
});
let last: UIMessage | undefined;
try {
for await (const snapshot of readUIMessageStream({ stream: segmentStream })) {
last = snapshot;
}
} catch {
// Skip malformed segment — tests can assert by inspecting what makes it through.
continue;
}
if (!last) continue;
if (isTrailing && cleanupAbortedParts) {
const cleaned = cleanupAbortedParts(last);
if (!cleaned.parts || cleaned.parts.length === 0) continue;
out.push(cleaned);
} else {
out.push(last);
}
}
return out;
}
@@ -0,0 +1,16 @@
import { resourceCatalog } from "@trigger.dev/core/v3";
import { StandardResourceCatalog } from "@trigger.dev/core/v3/workers";
/**
* Installs an in-memory `StandardResourceCatalog` and seeds a fake file
* context so task definitions (`task()`, `chat.agent()`, etc.) register
* their run functions where the test harness can look them up.
*
* This is invoked as a side-effect of importing `@trigger.dev/sdk/ai/test`.
*
* Without this, `registerTaskMetadata` short-circuits on a missing
* `_currentFileContext` and tasks silently fail to register.
*/
const catalog = new StandardResourceCatalog();
resourceCatalog.setGlobalResourceCatalog(catalog);
resourceCatalog.setCurrentFileContext("__test__.ts", "__test__");
@@ -0,0 +1,299 @@
import type {
AsyncIterableStream,
PipeStreamResult,
StreamWriteResult,
WriterStreamOptions,
} from "@trigger.dev/core/v3";
import { ensureReadableStream, ManualWaitpointPromise } from "@trigger.dev/core/v3";
import type { SessionPipeStreamOptions, SessionSubscribeOptions } from "../sessions.js";
import { SessionHandle, SessionInputChannel, SessionOutputChannel } from "../sessions.js";
/**
* Stub for `SessionInputChannel.wait` that skips the apiClient round-trip
* the production path makes via `createSessionStreamWaitpoint`. Without
* this override, every test that exercises the suspend fallback (e.g.
* the `chat.handover` idle-timeout case) throws `ApiClientMissingError`
* because `apiClientManager.clientOrThrow()` runs in a test process that
* has no `TRIGGER_SECRET_KEY`.
*
* The promise resolves with `{ ok: false, error }` when the harness
* aborts its run signal — that mimics production semantics (suspended
* until something happens, returns cleanly on abort) without making a
* network call.
*/
class TestSessionInputChannel extends SessionInputChannel {
constructor(
sessionId: string,
private readonly getAbortSignal: () => AbortSignal | undefined
) {
super(sessionId);
}
// Override only the `wait` path. `on` / `once` / `peek` / `send`
// continue to flow through the real `sessionStreams` global, which
// the mock task context installs as a `TestSessionStreamManager`.
wait<T = unknown>(): ManualWaitpointPromise<T> {
return new ManualWaitpointPromise<T>(
(resolve: (value: { ok: false; error: Error }) => void) => {
const signal = this.getAbortSignal();
if (!signal) {
// Harness hasn't wired up its run signal yet — nothing to abort
// on. Stay pending; the run loop should never reach this state
// in practice but we don't want to throw here either.
return;
}
const onAbort = () => {
resolve({
ok: false,
error: new Error("session.in.wait() aborted by test harness"),
});
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
);
}
}
/**
* Per-session in-memory state collected from `.out` writes during a test.
* Owned by the mock-chat-agent harness; updated by {@link TestSessionOutputChannel}.
*/
export type TestSessionOutState = {
/** Every chunk written to `.out`, in order of write. */
chunks: unknown[];
/** Registered write listeners (fired for each chunk). */
listeners: Set<(chunk: unknown) => void>;
};
function notify(state: TestSessionOutState, chunk: unknown): void {
state.chunks.push(chunk);
for (const listener of state.listeners) {
try {
listener(chunk);
} catch {
// Never let a listener error break stream writes
}
}
}
async function drainInto<T>(
source: AsyncIterable<T> | ReadableStream<T>,
state: TestSessionOutState
): Promise<void> {
const readable = ensureReadableStream(source);
const reader = readable.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
notify(state, value);
}
} finally {
try {
reader.releaseLock();
} catch {
// ignore
}
}
}
/**
* `.out` channel that captures writes in memory instead of piping to S2.
* Mirrors {@link SessionOutputChannel}'s public shape — `pipe` / `writer`
* / `append` / `read` — so the agent's existing code paths work unchanged.
*/
export class TestSessionOutputChannel extends SessionOutputChannel {
constructor(
sessionId: string,
private readonly state: TestSessionOutState
) {
super(sessionId);
}
async append<T>(value: T, _options?: SessionPipeStreamOptions): Promise<void> {
notify(this.state, value);
}
pipe<T>(
value: AsyncIterable<T> | ReadableStream<T>,
_options?: SessionPipeStreamOptions
): PipeStreamResult<T> {
const state = this.state;
const readChunks: T[] = [];
let resolveDone!: () => void;
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
(async () => {
const readable = ensureReadableStream(value);
const reader = readable.getReader();
try {
while (true) {
const { done: d, value: v } = await reader.read();
if (d) return;
readChunks.push(v as T);
notify(state, v);
}
} finally {
try {
reader.releaseLock();
} catch {
// ignore
}
resolveDone();
}
})().catch(() => {
resolveDone();
});
const replayStream = new ReadableStream<T>({
async start(controller) {
await done;
for (const chunk of readChunks) controller.enqueue(chunk);
controller.close();
},
});
const emptyResult: StreamWriteResult = {};
return {
get stream(): AsyncIterableStream<T> {
return replayStream as AsyncIterableStream<T>;
},
waitUntilComplete: async () => {
await done;
return emptyResult;
},
};
}
writer<T>(options: WriterStreamOptions<T>): PipeStreamResult<T> {
let controller!: ReadableStreamDefaultController<T>;
const ongoing: Promise<void>[] = [];
const state = this.state;
const stream = new ReadableStream<T>({
start(c) {
controller = c;
},
});
const safeEnqueue = (data: T) => {
try {
controller.enqueue(data);
} catch {
// Stream already closed
}
};
try {
const result = options.execute({
write(part) {
safeEnqueue(part);
notify(state, part);
},
merge(streamArg) {
ongoing.push(drainInto(streamArg, state).catch(() => {}));
},
});
if (result) {
ongoing.push(result.catch(() => {}));
}
} catch {
// Swallow — tests can inspect state.chunks
}
const done: Promise<void> = (async () => {
while (ongoing.length > 0) {
await ongoing.shift();
}
})().finally(() => {
try {
controller.close();
} catch {
// Already closed
}
});
const emptyResult: StreamWriteResult = {};
return {
get stream(): AsyncIterableStream<T> {
return stream as AsyncIterableStream<T>;
},
waitUntilComplete: async () => {
await done;
return emptyResult;
},
};
}
async read<T>(_options?: SessionSubscribeOptions<T>): Promise<AsyncIterableStream<T>> {
throw new Error(
"TestSessionOutputChannel.read() is not supported in the mock-chat-agent harness — " +
"inspect `harness.allChunks` / `harness.allRawChunks` instead."
);
}
/**
* Override the one-shot control-record path. In production this goes
* direct to S2 with header-form records; in tests we project it back
* into the chunk-shape the harness already understands (the listener
* watches for `{type: "trigger:turn-complete"}` to drive turn-complete
* latches). Returns an empty `StreamWriteResult` — tests don't observe
* the seq_num, and trim seeding only matters in production.
*/
async writeControl(
subtype: string,
extraHeaders?: ReadonlyArray<readonly [string, string]>
): Promise<StreamWriteResult> {
const synthetic: Record<string, unknown> = { type: `trigger:${subtype}` };
if (extraHeaders) {
for (const [name, value] of extraHeaders) {
if (name === "public-access-token") {
synthetic.publicAccessToken = value;
}
}
}
notify(this.state, synthetic);
return {};
}
/**
* No-op in the mock harness. Production trims keep `session.out` bounded;
* the in-memory `state.chunks` array doesn't need trimming and tests
* that care about trim behaviour exercise it via the real S2 code path.
*/
async trimTo(_earliestSeqNum: number): Promise<void> {
// Intentionally a no-op for the mock harness.
}
}
/**
* Construct a {@link SessionHandle} whose `.out` channel captures writes in
* memory and whose `.in` channel routes through the `sessionStreams`
* global for record subscriptions (`on` / `once` / `peek`) but stubs
* `wait()` to skip the apiClient round-trip — see
* {@link TestSessionInputChannel}.
*
* `getAbortSignal` lets the channel observe the harness's run signal so
* `wait()` resolves cleanly on close. Pass a getter (not the signal
* directly) so the channel reads it lazily — the harness creates its
* `AbortController` after the override is installed.
*/
export function createTestSessionHandle(
sessionId: string,
state: TestSessionOutState,
getAbortSignal: () => AbortSignal | undefined = () => undefined
): SessionHandle {
return new SessionHandle(sessionId, {
in: new TestSessionInputChannel(sessionId, getAbortSignal),
out: new TestSessionOutputChannel(sessionId, state),
});
}
+8
View File
@@ -0,0 +1,8 @@
import { timeout as timeoutApi } from "@trigger.dev/core/v3";
const MAXIMUM_MAX_DURATION = 2_147_483_647;
export const timeout = {
None: MAXIMUM_MAX_DURATION,
signal: timeoutApi.signal,
};
+4
View File
@@ -0,0 +1,4 @@
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import { VERSION } from "../version.js";
export const tracer = new TriggerTracer({ name: "@trigger.dev/sdk", version: VERSION });
@@ -0,0 +1,297 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager, sdkScope, taskContext } from "@trigger.dev/core/v3";
import { auth, configure } from "./auth.js";
import { runs } from "./runs.js";
import { TriggerClient } from "./triggerClient.js";
type CapturedRequest = {
url: string;
authorization: string | undefined;
branch: string | undefined;
};
function installFetchSpy() {
const captured: CapturedRequest[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input?.url ?? String(input));
const headers = new Headers(init?.headers);
captured.push({
url,
authorization: headers.get("authorization") ?? undefined,
branch: headers.get("x-trigger-branch") ?? undefined,
});
// Return a fake successful response shaped like an empty run retrieval.
return new Response(JSON.stringify({}), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return {
captured,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}
describe("TriggerClient", () => {
let fetchSpy: ReturnType<typeof installFetchSpy>;
beforeEach(() => {
apiClientManager.disable();
fetchSpy = installFetchSpy();
});
afterEach(() => {
fetchSpy.restore();
apiClientManager.disable();
taskContext.disable();
vi.unstubAllEnvs();
});
it("throws on first API call when no accessToken is configured anywhere", () => {
const client = new TriggerClient();
expect(() => client.runs.list({ limit: 1 })).toThrow(/TRIGGER_SECRET_KEY/);
});
it("falls back to env vars when constructor config is empty", async () => {
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_dev_env_token");
vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch");
const client = new TriggerClient();
await client.runs.retrieve("run_abc").catch(() => undefined);
expect(fetchSpy.captured).toHaveLength(1);
expect(fetchSpy.captured[0]!.authorization).toBe("Bearer tr_dev_env_token");
expect(fetchSpy.captured[0]!.branch).toBe("env-branch");
});
it("uses the instance accessToken and previewBranch on outgoing requests", async () => {
const client = new TriggerClient({
accessToken: "tr_preview_instance_token",
previewBranch: "signup-flow",
});
await client.runs.retrieve("run_abc").catch(() => undefined);
expect(fetchSpy.captured).toHaveLength(1);
const req = fetchSpy.captured[0]!;
expect(req.authorization).toBe("Bearer tr_preview_instance_token");
expect(req.branch).toBe("signup-flow");
});
it("fills missing fields from env, but explicit constructor values still win", async () => {
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_env_token");
vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch");
vi.stubEnv("TRIGGER_API_URL", "https://env.example.com");
const explicit = new TriggerClient({
accessToken: "tr_explicit",
previewBranch: "explicit-branch",
});
const fromEnv = new TriggerClient();
await Promise.all([
explicit.runs.retrieve("run_a").catch(() => undefined),
fromEnv.runs.retrieve("run_b").catch(() => undefined),
]);
const byRun = Object.fromEntries(
fetchSpy.captured.map((r) => [r.url.split("/runs/")[1]?.split(/[/?]/)[0], r])
);
expect(byRun["run_a"]!.authorization).toBe("Bearer tr_explicit");
expect(byRun["run_a"]!.branch).toBe("explicit-branch");
expect(byRun["run_b"]!.authorization).toBe("Bearer tr_env_token");
expect(byRun["run_b"]!.branch).toBe("env-branch");
expect(byRun["run_a"]!.url.startsWith("https://env.example.com/")).toBe(true);
});
it("does not leak instance config to the global apiClientManager", async () => {
configure({ accessToken: "tr_dev_global_token" });
const client = new TriggerClient({
accessToken: "tr_preview_instance_token",
previewBranch: "signup-flow",
});
await client.runs.retrieve("run_instance").catch(() => undefined);
await runs.retrieve("run_global").catch(() => undefined);
expect(fetchSpy.captured).toHaveLength(2);
expect(fetchSpy.captured[0]!.authorization).toBe("Bearer tr_preview_instance_token");
expect(fetchSpy.captured[0]!.branch).toBe("signup-flow");
expect(fetchSpy.captured[1]!.authorization).toBe("Bearer tr_dev_global_token");
expect(fetchSpy.captured[1]!.branch).toBeUndefined();
});
it("keeps two concurrent instances isolated from each other", async () => {
const prod = new TriggerClient({ accessToken: "tr_prod_key" });
const preview = new TriggerClient({
accessToken: "tr_preview_key",
previewBranch: "feature-x",
});
await Promise.all([
prod.runs.retrieve("run_a").catch(() => undefined),
preview.runs.retrieve("run_b").catch(() => undefined),
prod.runs.retrieve("run_c").catch(() => undefined),
preview.runs.retrieve("run_d").catch(() => undefined),
]);
expect(fetchSpy.captured).toHaveLength(4);
const byPath = Object.fromEntries(
fetchSpy.captured.map((r) => [r.url.split("/runs/")[1]?.split(/[/?]/)[0], r])
);
expect(byPath["run_a"]!.authorization).toBe("Bearer tr_prod_key");
expect(byPath["run_a"]!.branch).toBeUndefined();
expect(byPath["run_c"]!.authorization).toBe("Bearer tr_prod_key");
expect(byPath["run_c"]!.branch).toBeUndefined();
expect(byPath["run_b"]!.authorization).toBe("Bearer tr_preview_key");
expect(byPath["run_b"]!.branch).toBe("feature-x");
expect(byPath["run_d"]!.authorization).toBe("Bearer tr_preview_key");
expect(byPath["run_d"]!.branch).toBe("feature-x");
});
it("masks taskContext.ctx inside an isolated scope (default)", () => {
const fakeCtx = {
run: { id: "run_parent", isTest: true },
project: { ref: "proj_xyz" },
environment: { slug: "preview" },
} as any;
taskContext.setGlobalTaskContext({ ctx: fakeCtx } as any);
expect(taskContext.ctx).toBe(fakeCtx);
const observed = sdkScope.withScope(
{ apiClientConfig: { accessToken: "x" }, inheritContext: false },
() => taskContext.ctx
);
expect(observed).toBeUndefined();
});
it("exposes taskContext.ctx inside a scope when inheritContext is true", () => {
const fakeCtx = {
run: { id: "run_parent", isTest: true },
project: { ref: "proj_xyz" },
environment: { slug: "preview" },
} as any;
taskContext.setGlobalTaskContext({ ctx: fakeCtx } as any);
const observed = sdkScope.withScope(
{ apiClientConfig: { accessToken: "x" }, inheritContext: true },
() => taskContext.ctx
);
expect(observed).toBe(fakeCtx);
});
});
describe("configure()", () => {
beforeEach(() => {
apiClientManager.disable();
});
afterEach(() => {
apiClientManager.disable();
});
it("overrides previously-set configuration on a second call", async () => {
configure({ accessToken: "tr_first" });
expect(apiClientManager.accessToken).toBe("tr_first");
configure({ accessToken: "tr_second", previewBranch: "branch-b" });
expect(apiClientManager.accessToken).toBe("tr_second");
expect(apiClientManager.branchName).toBe("branch-b");
});
});
describe("auth.withAuth", () => {
beforeEach(() => {
apiClientManager.disable();
});
afterEach(() => {
apiClientManager.disable();
});
it("inherits TRIGGER_SECRET_KEY from env when called with a partial config", async () => {
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_dev_env_token");
let observed: string | undefined;
await auth.withAuth({ baseURL: "https://override.example.com" }, async () => {
observed = apiClientManager.accessToken;
});
// The scoped `inheritContext: true` path falls back to TRIGGER_SECRET_KEY
// so callers can override only baseURL without re-passing the token.
expect(observed).toBe("tr_dev_env_token");
// baseURL override still applies.
expect(
await auth.withAuth(
{ baseURL: "https://override.example.com" },
async () => apiClientManager.baseURL
)
).toBe("https://override.example.com");
});
it("composes nested withAuth: outer-scope fields flow into the inner scope", async () => {
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_env_token");
let observedBaseURL: string | undefined;
let observedAuth: string | undefined;
await auth.withAuth({ baseURL: "https://outer.example.com" }, async () => {
await auth.withAuth({ accessToken: "tr_inner_token" }, async () => {
observedBaseURL = apiClientManager.baseURL;
observedAuth = apiClientManager.accessToken;
});
});
expect(observedBaseURL).toBe("https://outer.example.com");
expect(observedAuth).toBe("tr_inner_token");
});
it("does not stomp on a parallel withAuth call with a different config", async () => {
configure({ accessToken: "tr_global" });
const tokenA = "tr_concurrent_a";
const tokenB = "tr_concurrent_b";
const settle = {
resolveA: () => {},
resolveB: () => {},
};
const gateA = new Promise<void>((r) => (settle.resolveA = r));
const gateB = new Promise<void>((r) => (settle.resolveB = r));
const runA = auth.withAuth({ accessToken: tokenA }, async () => {
// Suspend mid-scope so the parallel B scope opens while A is still pending.
await gateA;
return apiClientManager.accessToken;
});
const runB = auth.withAuth({ accessToken: tokenB }, async () => {
// Open B's scope first, then unblock A. If withAuth used the old
// mutate-and-restore pattern, A would observe tokenB or B's
// .finally would restore the wrong "original".
const seenInB = apiClientManager.accessToken;
settle.resolveA();
await gateB;
return seenInB;
});
settle.resolveB(); // let B finish after A reads
const [seenInA, seenInB] = await Promise.all([runA, runB]);
expect(seenInA).toBe(tokenA);
expect(seenInB).toBe(tokenB);
// Global remains unchanged after both scopes exit.
expect(apiClientManager.accessToken).toBe("tr_global");
});
});
@@ -0,0 +1,107 @@
import {
type ApiClientConfiguration,
apiClientManager,
sdkScope,
type SdkScope,
} from "@trigger.dev/core/v3";
import "@trigger.dev/core/v3/sdk-scope-storage";
import { auth } from "./auth.js";
import { batch } from "./batch.js";
import { deployments } from "./deployments.js";
import * as envvarsModule from "./envvars.js";
import * as promptsModule from "./prompts.js";
import * as queuesModule from "./queues.js";
import { runs } from "./runs.js";
import * as schedulesModule from "./schedules/index.js";
import { batchTrigger, trigger } from "./shared.js";
export type TriggerClientConfig = ApiClientConfiguration & {
/** Inherit ambient task context (parentRunId, lockToVersion, isTest) when called from inside a task. Default `false`. */
inheritContext?: boolean;
};
const tasksApi = { trigger, batchTrigger };
const batchInstanceKeys = ["trigger", "triggerByTask", "retrieve"] as const;
const schedulesInstanceKeys = [
"activate",
"create",
"deactivate",
"del",
"list",
"retrieve",
"update",
] as const;
const promptsInstanceKeys = [
"createOverride",
"list",
"promote",
"reactivateOverride",
"removeOverride",
"resolve",
"updateOverride",
"versions",
] as const;
const authInstanceKeys = [
"createPublicToken",
"createTriggerPublicToken",
"createBatchTriggerPublicToken",
] as const;
type TasksApi = typeof tasksApi;
type RunsApi = typeof runs;
type BatchApi = Pick<typeof batch, (typeof batchInstanceKeys)[number]>;
type DeploymentsApi = typeof deployments;
type EnvvarsApi = typeof envvarsModule;
type PromptsApi = Pick<typeof promptsModule, (typeof promptsInstanceKeys)[number]>;
type QueuesApi = typeof queuesModule;
type SchedulesApi = Pick<typeof schedulesModule, (typeof schedulesInstanceKeys)[number]>;
type AuthApi = Pick<typeof auth, (typeof authInstanceKeys)[number]>;
export class TriggerClient {
readonly tasks: TasksApi;
readonly runs: RunsApi;
readonly batch: BatchApi;
readonly deployments: DeploymentsApi;
readonly envvars: EnvvarsApi;
readonly prompts: PromptsApi;
readonly queues: QueuesApi;
readonly schedules: SchedulesApi;
readonly auth: AuthApi;
constructor(config: TriggerClientConfig = {}) {
const { inheritContext, ...partial } = config;
const scope: SdkScope = {
apiClientConfig: apiClientManager.resolveApiClientConfig(partial),
inheritContext: inheritContext ?? false,
};
this.tasks = bindToScope(tasksApi, scope);
this.runs = bindToScope(runs, scope);
this.batch = bindToScope(batch, scope, batchInstanceKeys);
this.deployments = bindToScope(deployments, scope);
this.envvars = bindToScope(envvarsModule, scope);
this.prompts = bindToScope(promptsModule, scope, promptsInstanceKeys);
this.queues = bindToScope(queuesModule, scope);
this.schedules = bindToScope(schedulesModule, scope, schedulesInstanceKeys);
this.auth = bindToScope(auth, scope, authInstanceKeys);
}
}
function bindToScope<T extends object, K extends keyof T = keyof T>(
api: T,
scope: SdkScope,
keys?: readonly K[]
): Pick<T, K> {
const targetKeys = (keys ?? (Object.keys(api) as K[])) as readonly K[];
const bound: Record<string, unknown> = {};
for (const key of targetKeys) {
const value = (api as Record<string, unknown>)[key as string];
bound[key as string] =
typeof value === "function"
? (...args: unknown[]) =>
sdkScope.withScope(scope, () => (value as (...a: unknown[]) => unknown)(...args))
: value;
}
return bound as unknown as Pick<T, K>;
}
@@ -0,0 +1,117 @@
import { describe, expectTypeOf, it } from "vitest";
import { batch } from "./batch.js";
import type { runs } from "./runs.js";
import type * as envvars from "./envvars.js";
import * as schedules from "./schedules/index.js";
import * as prompts from "./prompts.js";
import { auth } from "./auth.js";
import type { Task } from "./shared.js";
import { TriggerClient } from "./triggerClient.js";
// Stand-in task type used to verify generic inference flows through the proxy.
// Mirrors the shape returned by `task({...})` calls.
type ExampleTask = Task<"example", { to: string }, { sent: boolean }>;
const client = new TriggerClient({ accessToken: "tr_x" });
describe("TriggerClient surface — type-level guarantees", () => {
it("preserves generic inference on tasks.trigger<typeof t>", () => {
// If the proxy cast in bindToScope ever erodes generics, this fails:
// the return type degrades to `unknown` and `.id`/`.taskIdentifier`
// disappear.
type Returned = ReturnType<typeof client.tasks.trigger<ExampleTask>>;
expectTypeOf<Returned>().resolves.toHaveProperty("id");
expectTypeOf<Returned>().resolves.toHaveProperty("taskIdentifier");
});
it("preserves return type on runs.retrieve (no double-wrap)", () => {
// bindToScope wraps the impl as () => sdkScope.withScope(...). If the
// wrapper were typed loosely it could surface as Promise<ApiPromise<...>>.
// We want the original ApiPromise<...> to flow through unchanged.
const handle = client.runs.retrieve<ExampleTask>("run_x");
expectTypeOf(handle).toEqualTypeOf<ReturnType<typeof runs.retrieve<ExampleTask>>>();
// And it should be assignable to a plain Promise (since ApiPromise extends Promise).
expectTypeOf(handle).toMatchTypeOf<Promise<unknown>>();
});
it("preserves envvars.list overloads (projectRef+slug form AND zero-arg form)", () => {
// Two-arg form
expectTypeOf(client.envvars.list).toBeCallableWith("proj_1234", "dev");
// Zero-arg form (uses task context — still typeable at the call site)
expectTypeOf(client.envvars.list).toBeCallableWith();
});
});
describe("TriggerClient surface — curated subsets", () => {
it("instance.tasks drops inside-task-only and definition-time helpers", () => {
type Keys = keyof typeof client.tasks;
expectTypeOf<Keys>().toEqualTypeOf<"trigger" | "batchTrigger">();
// @ts-expect-error — triggerAndWait is not on the instance surface.
void client.tasks.triggerAndWait;
// @ts-expect-error — batchTriggerAndWait is not on the instance surface.
void client.tasks.batchTriggerAndWait;
// @ts-expect-error — triggerAndSubscribe requires a task context; not on the instance surface.
void client.tasks.triggerAndSubscribe;
// @ts-expect-error — hooks like onStart are task-definition-time, not on the client.
void client.tasks.onStart;
});
it("instance.batch drops the *AndWait variants that depend on the runtime", () => {
type Keys = keyof typeof client.batch;
expectTypeOf<Keys>().toEqualTypeOf<"trigger" | "triggerByTask" | "retrieve">();
// @ts-expect-error
void client.batch.triggerAndWait;
// @ts-expect-error
void client.batch.triggerByTaskAndWait;
// The module-level export still has them — sanity check we didn't change that.
expectTypeOf(batch).toHaveProperty("triggerAndWait");
});
it("instance.schedules drops `task` definition helper and `timezones` stateless helper", () => {
type Keys = keyof typeof client.schedules;
expectTypeOf<Keys>().toEqualTypeOf<
"activate" | "create" | "deactivate" | "del" | "list" | "retrieve" | "update"
>();
// @ts-expect-error
void client.schedules.task;
// @ts-expect-error
void client.schedules.timezones;
// Module-level export still has them.
expectTypeOf(schedules).toHaveProperty("task");
expectTypeOf(schedules).toHaveProperty("timezones");
});
it("instance.prompts drops `define`", () => {
// @ts-expect-error
void client.prompts.define;
// Module-level export still has it.
expectTypeOf(prompts).toHaveProperty("define");
});
it("instance.auth is the public-token subset only (no configure/withAuth)", () => {
type Keys = keyof typeof client.auth;
expectTypeOf<Keys>().toEqualTypeOf<
"createPublicToken" | "createTriggerPublicToken" | "createBatchTriggerPublicToken"
>();
// @ts-expect-error — configure is global-only, not on the instance.
void client.auth.configure;
// @ts-expect-error — withAuth is global-only.
void client.auth.withAuth;
// Module-level export still has them.
expectTypeOf(auth).toHaveProperty("configure");
expectTypeOf(auth).toHaveProperty("withAuth");
});
});
describe("TriggerClient surface — namespaces match their module sources", () => {
// These are the load-bearing assertions for the bindToScope cast. If the
// `as unknown as Pick<T, K>` ever drops or widens the underlying signatures,
// these break.
it("client.runs is structurally `typeof runs`", () => {
expectTypeOf(client.runs).toEqualTypeOf<typeof runs>();
});
it("client.envvars is structurally `typeof envvars`", () => {
expectTypeOf(client.envvars).toEqualTypeOf<typeof envvars>();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { usage as usageApi, taskContext } from "@trigger.dev/core/v3";
export type ComputeUsage = {
costInCents: number;
durationMs: number;
};
// What about run start cost and what should we call that? Some better names
export type CurrentUsage = {
compute: {
attempt: ComputeUsage;
total: ComputeUsage;
};
baseCostInCents: number;
totalCostInCents: number;
};
export const usage = {
/**
* Get the current running usage of this task run.
*
* @example
*
* ```typescript
* import { usage, task } from "@trigger.dev/sdk/v3";
*
* export const myTask = task({
* id: "my-task",
* run: async (payload, { ctx }) => {
* // ... Do a bunch of work
*
* const currentUsage = usage.getCurrent();
*
* // You have access to the current compute cost and duration up to this point
* console.log("Current attempt compute cost and duration", {
* cost: currentUsage.compute.attempt.costInCents,
* duration: currentUsage.compute.attempt.durationMs,
* });
*
* // You also can see the total compute cost and duration up to this point in the run, across all attempts
* console.log("Current total compute cost and duration", {
* cost: currentUsage.compute.total.costInCents,
* duration: currentUsage.compute.total.durationMs,
* });
*
* // You can see the base cost of the run, which is the cost of the run before any compute costs
* console.log("Total cost", {
* cost: currentUsage.totalCostInCents,
* baseCost: currentUsage.baseCostInCents,
* });
* },
* });
* ```
*/
getCurrent: (): CurrentUsage => {
const sample = usageApi.sample();
const initialState = usageApi.getInitialState();
const machine = taskContext.ctx?.machine;
const run = taskContext.ctx?.run;
if (!sample) {
return {
compute: {
attempt: {
costInCents: 0,
durationMs: 0,
},
total: {
costInCents: initialState.costInCents,
durationMs: initialState.cpuTime,
},
},
baseCostInCents: run?.baseCostInCents ?? 0,
totalCostInCents: initialState.costInCents + (run?.baseCostInCents ?? 0),
};
}
const currentCostInCents = machine?.centsPerMs ? sample.cpuTime * machine.centsPerMs : 0;
return {
compute: {
attempt: {
costInCents: currentCostInCents,
durationMs: sample.cpuTime,
},
total: {
costInCents: currentCostInCents + initialState.costInCents,
durationMs: sample.cpuTime + initialState.cpuTime,
},
},
baseCostInCents: run?.baseCostInCents ?? 0,
totalCostInCents: currentCostInCents + (run?.baseCostInCents ?? 0) + initialState.costInCents,
};
},
/**
* Measure the cost and duration of a function.
*
* @example
*
* ```typescript
* import { usage } from "@trigger.dev/sdk/v3";
*
* export const myTask = task({
* id: "my-task",
* run: async (payload, { ctx }) => {
* const { result, compute } = await usage.measure(async () => {
* // Do some work
* return "result";
* });
*
* console.log("Result", result);
* console.log("Cost and duration", { cost: compute.costInCents, duration: compute.durationMs });
* },
* });
* ```
*/
measure: async <T>(cb: () => Promise<T>): Promise<{ result: T; compute: ComputeUsage }> => {
const measurement = usageApi.start();
const result = await cb();
const sample = usageApi.stop(measurement);
const machine = taskContext.ctx?.machine;
const costInCents = machine?.centsPerMs ? sample.cpuTime * machine.centsPerMs : 0;
return {
result,
compute: {
costInCents,
durationMs: sample.cpuTime,
},
};
},
};
+720
View File
@@ -0,0 +1,720 @@
import { SpanStatusCode } from "@opentelemetry/api";
import type {
ApiPromise,
ApiRequestOptions,
CompleteWaitpointTokenResponseBody,
CreateWaitpointTokenRequestBody,
CreateWaitpointTokenResponse,
CreateWaitpointTokenResponseBody,
CursorPagePromise,
ListWaitpointTokensQueryParams,
WaitpointListTokenItem,
WaitpointRetrieveTokenResponse,
WaitpointTokenStatus,
WaitpointTokenTypedResult,
} from "@trigger.dev/core/v3";
import {
accessoryAttributes,
apiClientManager,
flattenAttributes,
ManualWaitpointPromise,
mergeRequestOptions,
runtime,
SemanticInternalAttributes,
taskContext,
WaitpointTimeoutError,
} from "@trigger.dev/core/v3";
import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
import { tracer } from "./tracer.js";
/**
* This creates a waitpoint token.
* You can use this to pause a run until you complete the waitpoint (or it times out).
*
* @example
*
* **Manually completing a token**
*
* ```ts
* const token = await wait.createToken({
* idempotencyKey: `approve-document-${documentId}`,
* timeout: "24h",
* tags: [`document-${documentId}`],
* });
*
* // Later, in a different part of your codebase, you can complete the waitpoint
* await wait.completeToken(token, {
* status: "approved",
* comment: "Looks good to me!",
* });
* ```
*
* @example
*
* **Completing a token with a webhook**
*
* ```ts
* const token = await wait.createToken({
* timeout: "10m",
* tags: ["replicate"],
* });
*
* // Later, in a different part of your codebase, you can complete the waitpoint
* await replicate.predictions.create({
* version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478",
* input: {
* prompt: "A painting of a cat by Andy Warhol",
* },
* // pass the provided URL to Replicate's webhook, so they can "callback"
* webhook: token.url,
* webhook_events_filter: ["completed"],
* });
*
* const prediction = await wait.forToken<Prediction>(token).unwrap();
* ```
*
* @param options - The options for the waitpoint token.
* @param requestOptions - The request options for the waitpoint token.
* @returns The waitpoint token.
*/
function createToken(
options?: CreateWaitpointTokenRequestBody,
requestOptions?: ApiRequestOptions
): ApiPromise<CreateWaitpointTokenResponse> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "wait.createToken()",
icon: "wait-token",
attributes: {
idempotencyKey: options?.idempotencyKey,
idempotencyKeyTTL: options?.idempotencyKeyTTL,
timeout: options?.timeout
? typeof options.timeout === "string"
? options.timeout
: options.timeout.toISOString()
: undefined,
tags: options?.tags,
},
onResponseBody: (body: CreateWaitpointTokenResponseBody, span) => {
span.setAttribute("id", body.id);
span.setAttribute("isCached", body.isCached);
span.setAttribute("url", body.url);
},
},
requestOptions
);
return apiClient.createWaitpointToken(options ?? {}, $requestOptions);
}
/**
* Lists waitpoint tokens with optional filtering and pagination.
* You can iterate over all the items in the result using a for-await-of loop (you don't need to think about pagination).
*
* @example
* Basic usage:
* ```ts
* // List all tokens
* for await (const token of wait.listTokens()) {
* console.log("Token ID:", token.id);
* }
* ```
*
* @example
* With filters:
* ```ts
* // List completed tokens from the last 24 hours with specific tags
* for await (const token of wait.listTokens({
* status: "COMPLETED",
* period: "24h",
* tags: ["important", "approval"],
* limit: 50
* })) {
* console.log("Token ID:", token.id);
* }
* ```
*
* @param params - Optional query parameters for filtering and pagination
* @param params.status - Filter by token status
* @param params.idempotencyKey - Filter by idempotency key
* @param params.tags - Filter by tags
* @param params.period - Filter by time period (e.g. "24h", "7d")
* @param params.from - Filter by start date
* @param params.to - Filter by end date
* @param params.limit - Number of items per page
* @param params.after - Cursor for next page
* @param params.before - Cursor for previous page
* @param requestOptions - Additional API request options
* @returns Waitpoint tokens that can easily be iterated over using a for-await-of loop
*/
function listTokens(
params?: ListWaitpointTokensQueryParams,
requestOptions?: ApiRequestOptions
): CursorPagePromise<typeof WaitpointListTokenItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "wait.listTokens()",
icon: "wait-token",
attributes: {
...flattenAttributes(params as Record<string, unknown>),
},
},
requestOptions
);
return apiClient.listWaitpointTokens(params, $requestOptions);
}
/**
* A waitpoint token that has been retrieved.
*
* If the status is `WAITING`, this means the waitpoint is still pending.
* For `COMPLETED` the `output` will be the data you passed in when completing the waitpoint.
* For `TIMED_OUT` there will be an `error`.
*/
export type WaitpointRetrievedToken<T> = {
id: string;
/** A URL that you can make a POST request to in order to complete the waitpoint. */
url: string;
status: WaitpointTokenStatus;
completedAt?: Date;
timeoutAt?: Date;
idempotencyKey?: string;
idempotencyKeyExpiresAt?: Date;
tags: string[];
createdAt: Date;
output?: T;
error?: Error;
};
/**
* Retrieves a waitpoint token by its ID.
*
* @example
* ```ts
* const token = await wait.retrieveToken("waitpoint_12345678910");
* console.log("Token status:", token.status);
* console.log("Token tags:", token.tags);
* ```
*
* @param token - The token to retrieve.
* This can be a string token ID or an object with an `id` property.
* @param requestOptions - Optional API request options.
* @returns The waitpoint token details, including the output or error if the waitpoint is completed or timed out.
*/
async function retrieveToken<T>(
token: string | { id: string },
requestOptions?: ApiRequestOptions
): Promise<WaitpointRetrievedToken<T>> {
const apiClient = apiClientManager.clientOrThrow();
const $tokenId = typeof token === "string" ? token : token.id;
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "wait.retrieveToken()",
icon: "wait-token",
attributes: {
id: $tokenId,
...accessoryAttributes({
items: [
{
text: $tokenId,
variant: "normal",
},
],
style: "codepath",
}),
},
onResponseBody: (body: WaitpointRetrieveTokenResponse, span) => {
span.setAttribute("id", body.id);
span.setAttribute("url", body.url);
span.setAttribute("status", body.status);
if (body.completedAt) {
span.setAttribute("completedAt", body.completedAt.toISOString());
}
if (body.timeoutAt) {
span.setAttribute("timeoutAt", body.timeoutAt.toISOString());
}
if (body.idempotencyKey) {
span.setAttribute("idempotencyKey", body.idempotencyKey);
}
if (body.idempotencyKeyExpiresAt) {
span.setAttribute("idempotencyKeyExpiresAt", body.idempotencyKeyExpiresAt.toISOString());
}
span.setAttribute("tags", body.tags);
span.setAttribute("createdAt", body.createdAt.toISOString());
},
},
requestOptions
);
const result = await apiClient.retrieveWaitpointToken($tokenId, $requestOptions);
const data = result.output
? await conditionallyImportAndParsePacket(
{ data: result.output, dataType: result.outputType ?? "application/json" },
apiClient
)
: undefined;
let error: Error | undefined = undefined;
let output: T | undefined = undefined;
if (result.outputIsError) {
error = new WaitpointTimeoutError(data.message);
} else {
output = data as T;
}
return {
id: result.id,
url: result.url,
status: result.status,
completedAt: result.completedAt,
timeoutAt: result.timeoutAt,
idempotencyKey: result.idempotencyKey,
idempotencyKeyExpiresAt: result.idempotencyKeyExpiresAt,
tags: result.tags,
createdAt: result.createdAt,
output,
error,
};
}
/**
* This completes a waitpoint token.
* You can use this to complete a waitpoint token that you created earlier.
*
* @example
*
* ```ts
* await wait.completeToken(token, {
* status: "approved",
* comment: "Looks good to me!",
* });
* ```
*
* @param token - The token to complete.
* @param data - The data to complete the waitpoint with.
* @param requestOptions - The request options for the waitpoint token.
* @returns The waitpoint token.
*/
async function completeToken<T>(
/**
* The token to complete.
* This can be a string token ID or an object with an `id` property.
*/
token: string | { id: string },
/**
* The data to complete the waitpoint with.
* This will be returned when you wait for the token.
*/
data: T,
requestOptions?: ApiRequestOptions
) {
const apiClient = apiClientManager.clientOrThrow();
const tokenId = typeof token === "string" ? token : token.id;
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "wait.completeToken()",
icon: "wait-token",
attributes: {
id: tokenId,
},
onResponseBody: (body: CompleteWaitpointTokenResponseBody, span) => {
span.setAttribute("success", body.success);
},
},
requestOptions
);
return apiClient.completeWaitpointToken(tokenId, { data }, $requestOptions);
}
export type CommonWaitOptions = {
/**
* An optional idempotency key for the waitpoint.
* If you use the same key twice (and the key hasn't expired), you will get the original waitpoint back.
*
* Note: This waitpoint may already be complete, in which case when you wait for it, it will immediately continue.
*/
idempotencyKey?: string;
/**
* When set, this means the passed in idempotency key will expire after this time.
* This means after that time if you pass the same idempotency key again, you will get a new waitpoint.
*/
idempotencyKeyTTL?: string;
};
export type WaitForOptions = WaitPeriod & CommonWaitOptions;
type WaitPeriod =
| {
seconds: number;
}
| {
minutes: number;
}
| {
hours: number;
}
| {
days: number;
}
| {
weeks: number;
}
| {
months: number;
}
| {
years: number;
};
export { WaitpointTimeoutError, ManualWaitpointPromise } from "@trigger.dev/core/v3";
const DURATION_WAIT_CHARGE_THRESHOLD_MS = 5000;
function printWaitBelowThreshold() {
console.warn(
`Waits of ${DURATION_WAIT_CHARGE_THRESHOLD_MS / 1000}s or less count towards compute usage.`
);
}
export const wait = {
for: async (options: WaitForOptions) => {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("wait.forToken can only be used from inside a task.run()");
}
const apiClient = apiClientManager.clientOrThrow();
const start = Date.now();
const durationInMs = calculateDurationInMs(options);
if (durationInMs <= DURATION_WAIT_CHARGE_THRESHOLD_MS) {
return tracer.startActiveSpan(
`wait.for()`,
async (span) => {
if (durationInMs <= 0) {
return;
}
printWaitBelowThreshold();
await new Promise((resolve) => setTimeout(resolve, durationInMs));
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
...accessoryAttributes({
items: [
{
text: nameForWaitOptions(options),
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
}
const date = new Date(start + durationInMs);
const result = await apiClient.waitForDuration(ctx.run.id, {
date: date,
idempotencyKey: options.idempotencyKey,
idempotencyKeyTTL: options.idempotencyKeyTTL,
});
return tracer.startActiveSpan(
`wait.for()`,
async (span) => {
await runtime.waitUntil(result.waitpoint.id, date);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: result.waitpoint.id,
...accessoryAttributes({
items: [
{
text: nameForWaitOptions(options),
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
},
until: async (options: { date: Date; throwIfInThePast?: boolean } & CommonWaitOptions) => {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("wait.forToken can only be used from inside a task.run()");
}
// Calculate duration in ms
const durationInMs = options.date.getTime() - Date.now();
if (durationInMs <= DURATION_WAIT_CHARGE_THRESHOLD_MS) {
return tracer.startActiveSpan(
`wait.for()`,
async (span) => {
if (durationInMs === 0) {
return;
}
if (durationInMs < 0) {
if (options.throwIfInThePast) {
throw new Error("Date is in the past");
}
return;
}
printWaitBelowThreshold();
await new Promise((resolve) => setTimeout(resolve, durationInMs));
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
...accessoryAttributes({
items: [
{
text: options.date.toISOString(),
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
}
const apiClient = apiClientManager.clientOrThrow();
const result = await apiClient.waitForDuration(ctx.run.id, {
date: options.date,
idempotencyKey: options.idempotencyKey,
idempotencyKeyTTL: options.idempotencyKeyTTL,
});
return tracer.startActiveSpan(
`wait.until()`,
async (span) => {
if (options.throwIfInThePast && options.date < new Date()) {
throw new Error("Date is in the past");
}
await runtime.waitUntil(result.waitpoint.id, options.date);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: result.waitpoint.id,
...accessoryAttributes({
items: [
{
text: options.date.toISOString(),
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
},
createToken,
listTokens,
completeToken,
retrieveToken,
/**
* This waits for a waitpoint token to be completed.
* It can only be used inside a task.run() block.
*
* @example
*
* ```ts
* const result = await wait.forToken<typeof ApprovalData>(token);
* if (!result.ok) {
* // The waitpoint timed out
* throw result.error;
* }
*
* // This will be the type ApprovalData
* const approval = result.output;
* ```
*
* @param token - The token to wait for.
* @param options - The options for the waitpoint token.
* @returns A promise that resolves to the result of the waitpoint. You can use `.unwrap()` to get the result and an error will throw.
*/
forToken: <T>(
/**
* The token to wait for.
* This can be a string token ID or an object with an `id` property.
*/
token: string | { id: string }
): ManualWaitpointPromise<T> => {
return new ManualWaitpointPromise<T>(async (resolve, reject) => {
try {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("wait.forToken can only be used from inside a task.run()");
}
const apiClient = apiClientManager.clientOrThrow();
const tokenId = typeof token === "string" ? token : token.id;
const result = await tracer.startActiveSpan(
`wait.forToken()`,
async (span) => {
const response = await apiClient.waitForWaitpointToken({
runFriendlyId: ctx.run.id,
waitpointFriendlyId: tokenId,
});
if (!response.success) {
throw new Error(`Failed to wait for wait token ${tokenId}`);
}
const result = await runtime.waitUntil(tokenId);
const data = result.output
? await conditionallyImportAndParsePacket(
{ data: result.output, dataType: result.outputType ?? "application/json" },
apiClient
)
: undefined;
if (result.ok) {
return {
ok: result.ok,
output: data,
} as WaitpointTokenTypedResult<T>;
} else {
const error = new WaitpointTimeoutError(data.message);
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
});
return {
ok: result.ok,
error,
} as WaitpointTokenTypedResult<T>;
}
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: tokenId,
id: tokenId,
...accessoryAttributes({
items: [
{
text: tokenId,
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
resolve(result);
} catch (error) {
reject(error);
}
});
},
};
function nameForWaitOptions(options: WaitForOptions): string {
if ("seconds" in options) {
return options.seconds === 1 ? `1 second` : `${options.seconds} seconds`;
}
if ("minutes" in options) {
return options.minutes === 1 ? `1 minute` : `${options.minutes} minutes`;
}
if ("hours" in options) {
return options.hours === 1 ? `1 hour` : `${options.hours} hours`;
}
if ("days" in options) {
return options.days === 1 ? `1 day` : `${options.days} days`;
}
if ("weeks" in options) {
return options.weeks === 1 ? `1 week` : `${options.weeks} weeks`;
}
if ("months" in options) {
return options.months === 1 ? `1 month` : `${options.months} months`;
}
if ("years" in options) {
return options.years === 1 ? `1 year` : `${options.years} years`;
}
return "NaN";
}
function calculateDurationInMs(options: WaitForOptions): number {
if ("seconds" in options) {
return options.seconds * 1000;
}
if ("minutes" in options) {
return options.minutes * 1000 * 60;
}
if ("hours" in options) {
return options.hours * 1000 * 60 * 60;
}
if ("days" in options) {
return options.days * 1000 * 60 * 60 * 24;
}
if ("weeks" in options) {
return options.weeks * 1000 * 60 * 60 * 24 * 7;
}
if ("months" in options) {
return options.months * 1000 * 60 * 60 * 24 * 30;
}
if ("years" in options) {
return options.years * 1000 * 60 * 60 * 24 * 365;
}
throw new Error("Invalid options");
}
+13
View File
@@ -0,0 +1,13 @@
import { waitUntil as core_waitUntil } from "@trigger.dev/core/v3";
/**
* waitUntil extends the lifetime of a task run until the provided promise settles.
* You can use this function to ensure that a task run does not complete until the promise resolves or rejects.
*
* Useful if you need to make sure something happens but you wait to continue doing other work in the task run.
*
* @param promise - The promise to wait for.
*/
export function waitUntil(promise: Promise<any>) {
return core_waitUntil.register({ promise, requiresResolving: () => true });
}
+171
View File
@@ -0,0 +1,171 @@
import { Webhook } from "@trigger.dev/core/v3";
import { subtle } from "../imports/uncrypto.js";
/**
* The type of error thrown when a webhook fails to parse or verify
*/
export class WebhookError extends Error {
constructor(message: string) {
super(message);
this.name = "WebhookError";
}
}
/** Header name used for webhook signatures */
const SIGNATURE_HEADER_NAME = "x-trigger-signature-hmacsha256";
/**
* Options for constructing a webhook event
*/
type ConstructEventOptions = {
/** Raw payload as string or Buffer */
payload: string | Buffer;
/** Signature header as string, Buffer, or string array */
header: string | Buffer | Array<string>;
};
/**
* Interface describing the webhook utilities
*/
interface Webhooks {
/**
* Constructs and validates a webhook event from an incoming request
* @param request - Either a Request object or ConstructEventOptions containing the payload and signature
* @param secret - Secret key used to verify the webhook signature
* @returns Promise resolving to a validated AlertWebhook object
* @throws {WebhookError} If validation fails or payload can't be parsed
*
* @example
* // Using with Request object
* const event = await webhooks.constructEvent(request, "webhook_secret");
*
* @example
* // Using with manual options
* const event = await webhooks.constructEvent({
* payload: rawBody,
* header: signatureHeader
* }, "webhook_secret");
*/
constructEvent(request: ConstructEventOptions | Request, secret: string): Promise<Webhook>;
/** Header name used for webhook signatures */
SIGNATURE_HEADER_NAME: string;
}
/**
* Webhook utilities for handling incoming webhook requests
*/
export const webhooks: Webhooks = {
constructEvent,
SIGNATURE_HEADER_NAME,
};
async function constructEvent(
request: ConstructEventOptions | Request,
secret: string
): Promise<Webhook> {
let payload: string;
let signature: string;
if (request instanceof Request) {
if (!secret) {
throw new WebhookError("Secret is required when passing a Request object");
}
const signatureHeader = request.headers.get(SIGNATURE_HEADER_NAME);
if (!signatureHeader) {
throw new WebhookError("No signature header found");
}
signature = signatureHeader;
payload = await request.text();
} else {
payload = request.payload.toString();
if (Array.isArray(request.header)) {
throw new WebhookError("Signature header cannot be an array");
}
signature = request.header.toString();
}
// Verify the signature
const isValid = await verifySignature(payload, signature, secret);
if (!isValid) {
throw new WebhookError("Invalid signature");
}
// Parse and validate the payload
try {
const jsonPayload = JSON.parse(payload);
const parsedPayload = Webhook.parse(jsonPayload);
return parsedPayload;
} catch (error) {
if (error instanceof Error) {
throw new WebhookError(`Webhook parsing failed: ${error.message}`);
}
throw new WebhookError("Webhook parsing failed");
}
}
/**
* Verifies the signature of a webhook payload
* @param payload - Raw payload string to verify
* @param signature - Expected signature to check against
* @param secret - Secret key used to generate the signature
* @returns Promise resolving to boolean indicating if signature is valid
* @throws {WebhookError} If signature verification process fails
*
* @example
* const isValid = await verifySignature(
* '{"event": "test"}',
* "abc123signature",
* "webhook_secret"
* );
*/
async function verifySignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
try {
if (!secret) {
throw new WebhookError("Secret is required for signature verification");
}
// Convert the payload and secret to buffers
const hashPayload = Buffer.from(payload, "utf-8");
const hmacSecret = Buffer.from(secret, "utf-8");
// Import the secret key
const key = await subtle.importKey(
"raw",
hmacSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
// Calculate the expected signature
const actualSignature = await subtle.sign("HMAC", key, hashPayload);
const actualSignatureHex = Buffer.from(actualSignature).toString("hex");
// Compare signatures using timing-safe comparison
return timingSafeEqual(signature, actualSignatureHex);
} catch (_error) {
throw new WebhookError("Signature verification failed");
}
}
// Timing-safe comparison to prevent timing attacks
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
+1
View File
@@ -0,0 +1 @@
export const VERSION = "0.0.0";
@@ -0,0 +1,280 @@
// Import the test entry point first so the resource catalog is installed —
// not strictly required for these helper-level tests, but keeps parity with
// the rest of the test suite and removes a potential foot-gun if a future
// edit introduces a chat.agent({...}) at module scope.
import "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__readChatSnapshotProductionPathForTests as readChatSnapshot,
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
type ChatSnapshotV1,
} from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Build a minimal ChatSnapshotV1 with `count` user messages. Used as the
* production-path test payload — `messages` is the only field the runtime
* inspects beyond `version`.
*/
function buildSnapshot(count = 1): ChatSnapshotV1 {
return {
version: 1,
savedAt: 1_000_000,
messages: Array.from({ length: count }, (_, i) => ({
id: `m${i}`,
role: "user" as const,
parts: [{ type: "text" as const, text: `hello ${i}` }],
})),
lastOutEventId: "evt-42",
};
}
/**
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
* client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve
* with the presigned URLs the test wants. Returns spies for assertion.
*/
function stubApiClient(opts: {
getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
}) {
const getChatSnapshotUrl = vi.fn(
opts.getChatSnapshotUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" }))
);
const createChatSnapshotUploadUrl = vi.fn(
opts.createChatSnapshotUploadUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" }))
);
const fakeClient = {
getChatSnapshotUrl,
createChatSnapshotUploadUrl,
};
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never);
return { getChatSnapshotUrl, createChatSnapshotUploadUrl };
}
/**
* Stub global `fetch` so the helpers see whatever Response (or throw) the
* test wants. Returns a spy keyed on the URL passed.
*/
function stubFetch(impl: (url: string, init?: RequestInit) => Promise<Response> | Response) {
const spy = vi.fn(impl);
vi.stubGlobal("fetch", spy);
return spy;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat snapshot helpers", () => {
// Suppress the runtime's `logger.warn` calls — they pollute output but
// don't change test outcomes. Restored in afterEach.
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
warnSpy.mockRestore();
});
describe("readChatSnapshot", () => {
it("returns the snapshot on a successful GET", async () => {
const { getChatSnapshotUrl } = stubApiClient({});
const snapshot = buildSnapshot(2);
stubFetch(
async () =>
new Response(JSON.stringify(snapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("session-1");
expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1");
expect(result).toMatchObject({
version: 1,
messages: snapshot.messages,
lastOutEventId: "evt-42",
});
});
it("returns undefined on 404 (fresh session, no snapshot yet)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Not Found", { status: 404 }));
const result = await readChatSnapshot("missing-session");
expect(result).toBeUndefined();
});
it("returns undefined on non-404 non-OK (e.g. 500)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Internal Error", { status: 500 }));
const result = await readChatSnapshot("flaky-session");
expect(result).toBeUndefined();
});
it("returns undefined when the response body is malformed JSON", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response("not-json-{[", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("malformed-session");
expect(result).toBeUndefined();
});
it("returns undefined on version mismatch (forward-compat)", async () => {
stubApiClient({});
// Future format the current runtime can't decode — runtime ignores it.
const futureSnapshot = {
version: 99,
savedAt: Date.now(),
messages: [],
};
stubFetch(
async () =>
new Response(JSON.stringify(futureSnapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("v99-session");
expect(result).toBeUndefined();
});
it("returns undefined when `messages` field is missing or wrong type", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response(JSON.stringify({ version: 1, savedAt: 1, messages: "not-an-array" }), {
status: 200,
})
);
const result = await readChatSnapshot("bad-shape-session");
expect(result).toBeUndefined();
});
it("returns undefined when fetch throws (network error)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ECONNREFUSED");
});
const result = await readChatSnapshot("offline-session");
expect(result).toBeUndefined();
});
it("returns undefined when presign call fails", async () => {
stubApiClient({
getChatSnapshotUrl: async () => {
throw new Error("presign denied");
},
});
// No fetch should fire — presign failed.
const fetchSpy = stubFetch(async () => new Response("nope", { status: 500 }));
const result = await readChatSnapshot("denied-session");
expect(result).toBeUndefined();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns undefined when the response is not an object", async () => {
stubApiClient({});
stubFetch(async () => new Response(JSON.stringify("just-a-string"), { status: 200 }));
const result = await readChatSnapshot("string-response");
expect(result).toBeUndefined();
});
});
describe("writeChatSnapshot", () => {
it("PUTs the snapshot JSON to the presigned URL", async () => {
const { createChatSnapshotUploadUrl } = stubApiClient({});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
const snapshot = buildSnapshot(3);
await writeChatSnapshot("session-2", snapshot);
expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2");
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("https://example.invalid/put");
expect((init as RequestInit).method).toBe("PUT");
expect((init as RequestInit).headers).toMatchObject({
"content-type": "application/json",
});
// Body is the JSON-stringified snapshot — round-trip to confirm.
const sentBody = JSON.parse((init as RequestInit).body as string);
expect(sentBody).toEqual(snapshot);
});
it("returns without throwing on a non-OK PUT response (warns)", async () => {
stubApiClient({});
stubFetch(async () => new Response("forbidden", { status: 403 }));
await expect(
writeChatSnapshot("forbidden-session", buildSnapshot())
).resolves.toBeUndefined();
});
it("returns without throwing on a fetch network error (warns)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ETIMEDOUT");
});
await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined();
});
it("returns without throwing when presign fails (warns)", async () => {
stubApiClient({
createChatSnapshotUploadUrl: async () => {
throw new Error("presign denied");
},
});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined();
// Presign failed → no PUT attempted.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("addresses reads and writes by the same sessionId", async () => {
// Round-trip check: both presign methods receive the same sessionId.
// The canonical key (`sessions/{id}/snapshot.json`) lives server-side
// now, so the SDK has no key string to compare — sessionId equality
// is the SDK-visible invariant.
const { getChatSnapshotUrl } = stubApiClient({
getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
});
stubFetch(async () => new Response(null, { status: 404 }));
await readChatSnapshot("round-trip-session");
const [readArg] = getChatSnapshotUrl.mock.calls[0]!;
const { createChatSnapshotUploadUrl } = stubApiClient({
createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
});
stubFetch(async () => new Response(null, { status: 200 }));
await writeChatSnapshot("round-trip-session", buildSnapshot());
const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!;
expect(readArg).toBe(writeArg);
expect(readArg).toBe("round-trip-session");
});
});
});
@@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "ai";
import {
TriggerChatTransport,
type ChatTransportEvent,
type TriggerChatTransportOptions,
} from "../src/v3/chat.js";
// ── Helpers ────────────────────────────────────────────────────────────
function user(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function jsonOk(): Response {
return new Response("{}", { status: 200 });
}
/** Build a `text/event-stream` Response from raw SSE text (v1 wire). */
function sseResponse(frames: string): Response {
return new Response(frames, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
/** SSE body: one data chunk then a legacy turn-complete control chunk. */
const SSE_ONE_TURN = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"hello"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
async function readAll(stream: ReadableStream<unknown>): Promise<unknown[]> {
const out: unknown[] = [];
const reader = stream.getReader();
while (true) {
const next = await reader.read();
if (next.done) return out;
out.push(next.value);
}
}
function makeTransport(overrides: Partial<TriggerChatTransportOptions> = {}) {
const events: ChatTransportEvent[] = [];
const transport = new TriggerChatTransport({
task: "test-task",
accessToken: async () => "tok_test",
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
onEvent: (event) => events.push(event),
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(SSE_ONE_TURN),
...overrides,
});
return { transport, events };
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("transport send events", () => {
it("emits message-sent for a successful submit and the full stream lifecycle", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
const sent = events[0] as Extract<ChatTransportEvent, { type: "message-sent" }>;
expect(sent.chatId).toBe("c1");
expect(sent.messageId).toBe("u-1");
expect(sent.source).toBe("submit-message");
expect(sent.durationMs).toBeGreaterThanOrEqual(0);
expect(sent.timestamp).toBeGreaterThan(0);
expect(sent.partId).toMatch(/[0-9a-f-]{36}/);
expect(sent.bodyBytes).toBeGreaterThan(0);
const connected = events[1] as Extract<ChatTransportEvent, { type: "stream-connected" }>;
expect(connected.resumed).toBe(false);
expect(connected.messageId).toBe("u-1");
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("text-delta");
expect(firstChunk.lastEventId).toBe("1");
expect(firstChunk.messageId).toBe("u-1");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
const turnCompleted = events[3] as Extract<ChatTransportEvent, { type: "turn-completed" }>;
expect(turnCompleted.messageId).toBe("u-1");
expect(turnCompleted.sinceSendMs).toBeGreaterThanOrEqual(0);
expect(turnCompleted.lastEventId).toBe("2");
});
it("emits message-send-failed with the HTTP status when the append fails", async () => {
const { transport, events } = makeTransport({
fetch: async () => new Response("too large", { status: 413 }),
});
await expect(
transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
})
).rejects.toThrow();
expect(events).toHaveLength(1);
const failed = events[0] as Extract<ChatTransportEvent, { type: "message-send-failed" }>;
expect(failed.type).toBe("message-send-failed");
expect(failed.source).toBe("submit-message");
expect(failed.messageId).toBe("u-1");
expect(failed.status).toBe(413);
expect(failed.error).toBeInstanceOf(Error);
});
it("emits steer events from sendPendingMessage without changing its boolean result", async () => {
const { transport, events } = makeTransport();
const ok = await transport.sendPendingMessage("c1", user("steer", "u-2"));
expect(ok).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "steer", messageId: "u-2" });
const failing = makeTransport({ fetch: async () => new Response("nope", { status: 500 }) });
const notOk = await failing.transport.sendPendingMessage("c1", user("steer", "u-3"));
expect(notOk).toBe(false);
expect(failing.events[0]).toMatchObject({
type: "message-send-failed",
source: "steer",
status: 500,
});
});
it("emits action and stop send events", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendAction("c1", { type: "undo" });
await readAll(stream);
expect(events[0]).toMatchObject({ type: "message-sent", source: "action" });
events.length = 0;
const stopped = await transport.stopGeneration("c1");
expect(stopped).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "stop" });
});
it("swallows exceptions thrown by the onEvent callback", async () => {
const { transport } = makeTransport({
onEvent: () => {
throw new Error("observer exploded");
},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
const chunks = await readAll(stream);
expect(chunks.length).toBeGreaterThan(0);
});
});
describe("transport stream events", () => {
it("marks reconnectToStream subscriptions as resumed", async () => {
const { transport, events } = makeTransport({
sessions: {
c1: { publicAccessToken: "tok_test", isStreaming: true, lastEventId: "1" },
},
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
expect(stream).not.toBeNull();
await readAll(stream!);
const connected = events.find((e) => e.type === "stream-connected") as Extract<
ChatTransportEvent,
{ type: "stream-connected" }
>;
expect(connected.resumed).toBe(true);
expect(events.some((e) => e.type === "turn-completed")).toBe(true);
});
it("re-arms first-chunk per turn on a watch-mode stream", async () => {
const TWO_TURNS = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"turn one"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
`id: 3`,
`data: {"type":"text-delta","id":"t2","delta":"turn two"}`,
``,
`id: 4`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const { transport, events } = makeTransport({
watch: true,
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } },
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS),
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
await readAll(stream!);
expect(events.filter((e) => e.type === "first-chunk")).toHaveLength(2);
expect(events.filter((e) => e.type === "turn-completed")).toHaveLength(2);
});
it("emits the full lifecycle on the headStart first-turn path", async () => {
const handoverSse = [
`data: {"type":"start","messageId":"a-1"}`,
``,
`data: {"type":"text-delta","id":"t1","delta":"warm"}`,
``,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(handoverSse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({
headStart: "/api/chat",
sessions: {},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
expect(events[0]).toMatchObject({ source: "head-start", messageId: "u-hs" });
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("start");
expect(firstChunk.messageId).toBe("u-hs");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the headStart response body fails mid-read", async () => {
const encoder = new TextEncoder();
const failingBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: {"type":"text-delta","id":"t1","delta":"w"}\n\n`));
controller.error(new Error("network drop"));
},
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(failingBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({ headStart: "/api/chat", sessions: {} });
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs-err",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow("network drop");
const streamError = events.find((e) => e.type === "stream-error");
expect(streamError).toBeDefined();
expect(events.some((e) => e.type === "turn-completed")).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the output stream fails unrecoverably", async () => {
const { transport, events } = makeTransport({
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : new Response("gone", { status: 400 }),
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow();
expect(events.some((e) => e.type === "stream-error")).toBe(true);
expect(events.some((e) => e.type === "stream-connected")).toBe(false);
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "ai";
import { TriggerChatTransport, type TriggerChatTransportOptions } from "../src/v3/chat.js";
// A send's `.out` stream must close on the turn that consumed its own appended
// record, not an earlier turn-complete (e.g. a racing undo action). The seq
// comes back from `/in/append`; correlation headers ride the v2 batch wire.
function user(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
type BatchRecord = {
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
};
function batchResponse(records: BatchRecord[]): Response {
const frames = records
.map((r) => `event: batch\ndata: ${JSON.stringify({ records: [r] })}\n\n`)
.join("");
return new Response(frames, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
});
}
/** A turn-complete control record whose committed `.in` cursor is `inCursor`. */
function turnComplete(seqNum: number, inCursor: number): BatchRecord {
return {
body: "",
seq_num: seqNum,
timestamp: seqNum,
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", String(inCursor)],
],
};
}
function textDelta(seqNum: number, text: string): BatchRecord {
return {
body: JSON.stringify({ data: { type: "text-delta", id: "t1", delta: text }, id: "m1" }),
seq_num: seqNum,
timestamp: seqNum,
headers: [],
};
}
function inResponse(seq?: number): Response {
return new Response(JSON.stringify(seq === undefined ? { ok: true } : { ok: true, seq }), {
status: 200,
});
}
async function readDeltas(stream: ReadableStream<unknown>): Promise<string[]> {
const out: string[] = [];
const reader = stream.getReader();
while (true) {
const next = await reader.read();
if (next.done) return out;
const chunk = next.value as { type?: string; delta?: string };
if (chunk?.type === "text-delta" && typeof chunk.delta === "string") out.push(chunk.delta);
}
}
function makeTransport(out: Response, inSeq: number | undefined) {
const options: TriggerChatTransportOptions = {
task: "test-task",
accessToken: async () => "tok_test",
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
fetch: async (_url, _init, ctx) => (ctx.endpoint === "in" ? inResponse(inSeq) : out),
};
return new TriggerChatTransport(options);
}
async function submit(transport: TriggerChatTransport): Promise<string[]> {
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
return readDeltas(stream);
}
describe("transport turn correlation", () => {
it("skips an earlier turn's turn-complete and closes on its own", async () => {
// Append seq 5; the undo turn's complete (cursor 4) must be skipped.
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
const deltas = await submit(makeTransport(out, 5));
expect(deltas).toEqual(["56"]);
});
it("does not skip when the turn-complete is at the send's own seq", async () => {
const out = batchResponse([textDelta(10, "56"), turnComplete(11, 5)]);
const deltas = await submit(makeTransport(out, 5));
expect(deltas).toEqual(["56"]);
});
it("without an append seq, closes on the first turn-complete (legacy webapp)", async () => {
// No seq => no baseline => old behavior: close on the first turn-complete.
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
const deltas = await submit(makeTransport(out, undefined));
expect(deltas).toEqual([]);
});
});
@@ -0,0 +1,635 @@
// Import the test harness FIRST — installs the resource catalog so
// `chat.agent()` calls below register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { simulateReadableStream, streamText, tool } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { z } from "zod";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
],
});
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.handover", () => {
it("handover-skip (error path) exits cleanly without firing turn hooks", async () => {
// `handover-skip` is now only sent when the customer's handler
// ABORTS before producing a finishReason (dispatch error). The
// agent run exits clean, no hooks fire. Normal pure-text and
// tool-call finishes go through `kind: "handover"`.
const onChatStart = vi.fn();
const onTurnStart = vi.fn();
const onTurnComplete = vi.fn();
const onPreload = vi.fn();
const runFn = vi.fn();
const agent = chat.agent({
id: "chat.handover.skip",
onPreload,
onChatStart,
onTurnStart,
onTurnComplete,
run: async ({ messages, signal }) => {
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-skip",
mode: "handover-prepare",
});
try {
await harness.sendHandoverSkip();
// Give any deferred work a tick.
await new Promise((r) => setTimeout(r, 20));
// No turn hooks fire on skip — the run boots, waits, and exits.
expect(onPreload).not.toHaveBeenCalled();
expect(onTurnStart).not.toHaveBeenCalled();
expect(onTurnComplete).not.toHaveBeenCalled();
expect(runFn).not.toHaveBeenCalled();
// No content chunks were emitted — only the boot scaffolding (if any).
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
it("pure-text head-start (isFinal: true) runs full hook chain WITHOUT calling streamText", async () => {
// Pure-text first turn: customer's step 1 produced the final
// response. The agent runs onChatStart → onTurnStart →
// onTurnComplete (so persistence works), but SKIPS the user's
// run() callback entirely (no LLM call, no streamText).
// onTurnComplete fires with the customer's partial as
// `responseMessage`.
const order: string[] = [];
const runFn = vi.fn();
let capturedResponse: { id?: string; partTypes?: string[]; firstText?: string } | undefined;
const agent = chat.agent({
id: "chat.handover.pure-text",
onChatStart: () => {
order.push("onChatStart");
},
onTurnStart: () => {
order.push("onTurnStart");
},
onTurnComplete: ({ responseMessage }) => {
order.push("onTurnComplete");
capturedResponse = {
id: responseMessage?.id,
partTypes: (responseMessage?.parts ?? []).map((p) => p.type),
firstText: (responseMessage?.parts ?? [])
.filter((p) => p.type === "text")
.map((p) => (p as { text?: string }).text || "")
.join(""),
};
},
run: async ({ messages, signal }) => {
// Should NOT be called for isFinal: true.
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-final",
mode: "handover-prepare",
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [{ type: "text", text: "Hi there, hope you're well." }],
},
],
messageId: "asst-msg-1",
isFinal: true,
});
// `onTurnComplete` fires AFTER the `trigger:turn-complete` chunk,
// and the harness's `sendHandover` resolves on that chunk —
// give onTurnComplete a tick to run.
await new Promise((r) => setTimeout(r, 30));
// All three hooks fired in order.
expect(order).toEqual(["onChatStart", "onTurnStart", "onTurnComplete"]);
// The user's run() was NEVER invoked — no LLM call from the agent.
expect(runFn).not.toHaveBeenCalled();
// onTurnComplete saw the customer's partial as responseMessage,
// with the matching messageId for browser-side merging.
expect(capturedResponse).toBeDefined();
expect(capturedResponse!.id).toBe("asst-msg-1");
expect(capturedResponse!.partTypes).toContain("text");
expect(capturedResponse!.firstText).toBe("Hi there, hope you're well.");
} finally {
await harness.close();
}
});
it("handover with schema-only pending tool-call resumes via approval-driven execution", async () => {
// Customer-side tools are schema-only (no `execute` fn) — AI SDK
// doesn't execute them, so `result.response.messages` after step 1
// contains JUST the assistant message with the pending tool-call.
// `chat-server.ts` reshapes this into AI SDK's tool-approval round
// (assistant + tool-approval-request, tool with tool-approval-response)
// before sending the handover signal. That's the wire shape this
// test simulates.
//
// The agent ships the same tool — but with the heavy `execute` fn.
// When the next `streamText` runs, AI SDK's initial-tool-execution
// branch (stream-text.ts:1342-1486) sees the approval round, runs
// the agent-side execute, and synthesizes a tool-result before the
// step-2 LLM call.
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({
city,
temp: 22,
}));
const weatherTool = tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: toolExecute,
});
const stepTwoStream = textStream("the weather in tokyo is 22°C");
const agent = chat.agent({
id: "chat.handover.schema-only-tool",
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: stepTwoStream }),
}),
messages,
tools: { weather: weatherTool },
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-schema-only",
mode: "handover-prepare",
});
try {
const turn = await harness.sendHandover({
isFinal: false, // pending tool-call → agent runs streamText
partialAssistantMessage: [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "weather",
input: { city: "tokyo" },
},
{
type: "tool-approval-request",
approvalId: "handover-approval-1",
toolCallId: "tc-1",
},
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-1",
approved: true,
},
],
},
],
});
// The agent-side execute ran (this is the whole point of the
// schema-only-on-customer pattern).
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
// Step-2 produced text was streamed through session.out.
const text = turn.chunks
.filter((c) => c.type === "text-delta")
.map((c) => (c as { delta: string }).delta)
.join("");
expect(text).toContain("tokyo");
expect(text).toContain("22°C");
} finally {
await harness.close();
}
});
it("pure-text head-start preserves reasoning parts in the response (TRI-10716)", async () => {
// Extended-thinking models stream a reasoning part in step 1. The
// synthesized partial must carry it (with provider metadata, so an
// Anthropic signature survives a UIMessage -> ModelMessage round
// trip) or the durable history loses the step-1 thinking.
let captured: { partTypes?: string[]; reasoningText?: string; meta?: unknown } | undefined;
const agent = chat.agent({
id: "chat.handover.reasoning",
onTurnComplete: ({ responseMessage }) => {
const parts = responseMessage?.parts ?? [];
captured = {
partTypes: parts.map((p) => p.type),
reasoningText: parts
.filter((p) => p.type === "reasoning")
.map((p) => (p as { text?: string }).text || "")
.join(""),
meta: (
parts.find((p) => p.type === "reasoning") as { providerMetadata?: unknown } | undefined
)?.providerMetadata,
};
},
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-reasoning",
mode: "handover-prepare",
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking about the greeting",
providerOptions: { anthropic: { signature: "sig-abc" } },
},
{ type: "text", text: "Hello!" },
],
},
],
messageId: "asst-reason-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 30));
expect(captured).toBeDefined();
expect(captured!.partTypes).toEqual(["reasoning", "text"]);
expect(captured!.reasoningText).toBe("thinking about the greeting");
expect(captured!.meta).toEqual({ anthropic: { signature: "sig-abc" } });
} finally {
await harness.close();
}
});
it("pure-text head-start (isFinal: true) with hydrateMessages persists the partial (TRI-10715)", async () => {
// Same as the pure-text case above, but the customer registers
// `hydrateMessages` (the documented DB-as-source-of-truth pattern).
// The head-start user message must reach the hydrate hook as
// `incomingMessages`, and the warm route's partial must land in the
// accumulator so `onTurnComplete` carries the full first turn.
const runFn = vi.fn();
const stored: { id: string; role: string; parts: unknown[] }[] = [];
const hydrateIncomingRoles: string[] = [];
let captured: { responseId?: string; responseText?: string; roles?: string[] } | undefined;
const agent = chat.agent({
id: "chat.handover.hydrate-pure-text",
hydrateMessages: async ({ incomingMessages }) => {
hydrateIncomingRoles.push(...incomingMessages.map((m) => m.role));
for (const m of incomingMessages) {
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
}
return [...stored] as never;
},
onTurnComplete: ({ responseMessage, uiMessages }) => {
captured = {
responseId: responseMessage?.id,
responseText: (responseMessage?.parts ?? [])
.filter((p) => p.type === "text")
.map((p) => (p as { text?: string }).text || "")
.join(""),
roles: uiMessages.map((m) => m.role),
};
},
run: async ({ messages, signal }) => {
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-hydrate-final",
mode: "handover-prepare",
headStartMessages: [
{ id: "hs-user-1", role: "user", parts: [{ type: "text", text: "say hi" }] },
],
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [{ type: "text", text: "Hi there, hope you're well." }],
},
],
messageId: "asst-hydrate-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 30));
// isFinal — the agent never calls the user's run().
expect(runFn).not.toHaveBeenCalled();
// The head-start user message reached the hydrate hook as incoming.
expect(hydrateIncomingRoles).toContain("user");
// onTurnComplete carries the full first turn: user + the warm
// route's assistant, under the handover messageId.
expect(captured).toBeDefined();
expect(captured!.roles).toEqual(["user", "assistant"]);
expect(captured!.responseId).toBe("asst-hydrate-1");
expect(captured!.responseText).toBe("Hi there, hope you're well.");
} finally {
await harness.close();
}
});
it("tool-call handover (isFinal: false) with hydrateMessages resumes from step 2 (TRI-10715)", async () => {
// Hydrate variant of the schema-only tool-call case: the spliced
// partial (assistant + approval round) must reach the agent's
// streamText so AI SDK executes the pending tool instead of
// re-running step 1 from scratch against an empty/short prompt.
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const weatherTool = tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: toolExecute,
});
const stored: { id: string; role: string; parts: unknown[] }[] = [];
let runMessageRoles: string[] | undefined;
let captured: { roles?: string[]; assistantIds?: (string | undefined)[] } | undefined;
const agent = chat.agent({
id: "chat.handover.hydrate-schema-only-tool",
hydrateMessages: async ({ incomingMessages }) => {
for (const m of incomingMessages) {
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
}
return [...stored] as never;
},
onTurnComplete: ({ uiMessages }) => {
captured = {
roles: uiMessages.map((m) => m.role),
assistantIds: uiMessages.filter((m) => m.role === "assistant").map((m) => m.id),
};
},
run: async ({ messages, signal }) => {
runMessageRoles = messages.map((m) => m.role);
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages,
tools: { weather: weatherTool },
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-hydrate-tool",
mode: "handover-prepare",
headStartMessages: [
{ id: "hs-user-2", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] },
],
});
try {
const turn = await harness.sendHandover({
isFinal: false,
messageId: "asst-hydrate-2",
partialAssistantMessage: [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{
type: "tool-call",
toolCallId: "tc-h1",
toolName: "weather",
input: { city: "tokyo" },
},
{
type: "tool-approval-request",
approvalId: "handover-approval-h1",
toolCallId: "tc-h1",
},
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-h1",
approved: true,
},
],
},
],
});
await new Promise((r) => setTimeout(r, 30));
// The resume prompt contained the full splice: user + partial
// assistant + approval round — NOT an empty/user-only prompt.
expect(runMessageRoles).toEqual(["user", "assistant", "tool"]);
// AI SDK's initial-tool-execution branch ran the agent-side
// execute (no step-1 re-run).
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
// Step-2 text streamed through session.out.
const text = turn.chunks
.filter((c) => c.type === "text-delta")
.map((c) => (c as { delta: string }).delta)
.join("");
expect(text).toContain("tokyo");
// One assistant in the final chain, under the handover messageId.
expect(captured).toBeDefined();
expect(captured!.roles).toEqual(["user", "assistant"]);
expect(captured!.assistantIds).toEqual(["asst-hydrate-2"]);
} finally {
await harness.close();
}
});
it("onTurnStart fires after the handover signal arrives (lazy)", async () => {
// Hooks should not fire during the wait — only once handover lands
// and a real turn begins. Verifies the order so customers can
// mutate `chat.history` inside `onTurnStart` knowing the partial
// assistant message is in scope.
const events: string[] = [];
const agent = chat.agent({
id: "chat.handover.lazy-hooks",
onPreload: () => {
events.push("onPreload");
},
onChatStart: () => {
events.push("onChatStart");
},
onTurnStart: () => {
events.push("onTurnStart");
},
onTurnComplete: () => {
events.push("onTurnComplete");
},
run: async ({ messages, signal }) => {
events.push("run");
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-lazy",
mode: "handover-prepare",
});
try {
// Before the signal lands, no hook should have fired.
await new Promise((r) => setTimeout(r, 20));
expect(events).toEqual([]);
await harness.sendHandover({
isFinal: false, // exercise the full streamText path
partialAssistantMessage: [
{ role: "assistant", content: [{ type: "text", text: "warming up" }] },
],
});
// Let any deferred onTurnComplete fire.
await new Promise((r) => setTimeout(r, 20));
// onPreload never fires for handover-prepare. Everything else
// fires once the partial lands — onChatStart still runs (first
// turn invariant), then onTurnStart, run, onTurnComplete.
expect(events).not.toContain("onPreload");
expect(events).toContain("onChatStart");
expect(events).toContain("onTurnStart");
expect(events).toContain("run");
expect(events).toContain("onTurnComplete");
// Order: hooks before run, run before onTurnComplete.
expect(events.indexOf("onTurnStart")).toBeLessThan(events.indexOf("run"));
expect(events.indexOf("run")).toBeLessThan(events.indexOf("onTurnComplete"));
} finally {
await harness.close();
}
});
it("idle timeout exits cleanly when no handover signal is sent", async () => {
// Customer's POST handler crashed before signaling. The agent
// should not hang forever — wait the configured idleTimeoutInSeconds
// and exit, just like the handover-skip case.
const onTurnStart = vi.fn();
const onTurnComplete = vi.fn();
const agent = chat.agent({
id: "chat.handover.idle-timeout",
idleTimeoutInSeconds: 1, // 1s — enough for the wait + exit.
onTurnStart,
onTurnComplete,
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("never") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-timeout",
mode: "handover-prepare",
});
try {
// Wait long enough for the idle timeout to fire.
await new Promise((r) => setTimeout(r, 1500));
expect(onTurnStart).not.toHaveBeenCalled();
expect(onTurnComplete).not.toHaveBeenCalled();
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,426 @@
// Import the test harness FIRST — installs the resource catalog so the
// chat task functions below register correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { simulateReadableStream, streamText, tool } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import type { ModelMessage, UIMessage } from "ai";
import { z } from "zod";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
],
});
}
type Capture = {
skipped?: boolean;
isFinal?: boolean;
handover?: { isFinal: boolean } | null;
uiMessages?: Array<{ id: string; role: string; partTypes: string[] }>;
};
function snapshot(uiMessages: UIMessage[]): Capture["uiMessages"] {
return uiMessages.map((m) => ({
id: m.id,
role: m.role,
partTypes: (m.parts ?? []).map((p) => p.type),
}));
}
// A pure-text partial (the warm step-1 response, isFinal: true).
const PURE_TEXT_PARTIAL: ModelMessage[] = [
{ role: "assistant", content: [{ type: "text", text: "Hi there, hope you're well." }] },
];
// A tool-call partial reshaped server-side into the approval round (isFinal: false).
const TOOL_CALL_PARTIAL: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{ type: "tool-call", toolCallId: "tc-1", toolName: "weather", input: { city: "tokyo" } },
{
type: "tool-approval-request",
approvalId: "handover-approval-1",
toolCallId: "tc-1",
} as never,
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-1",
approved: true,
} as never,
],
},
];
function weatherToolWithExecute(execute: (input: { city: string }) => Promise<unknown>) {
return tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: execute as never,
});
}
// ── chat.customAgent + headStart handover ───────────────────────────────
describe("chat.customAgent + headStart handover", () => {
it("consumeHandover skip → clean exit, no turn-complete", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.skip",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
runAfter();
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-skip", mode: "handover-prepare" });
try {
await harness.sendHandoverSkip();
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(true);
expect(capture.isFinal).toBe(false);
expect(runAfter).not.toHaveBeenCalled();
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: true (pure text) → partial spliced, no streamText", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.final",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
capture.uiMessages = snapshot(conversation.uiMessages);
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
runAfter();
},
});
const harness = mockChatAgent(agent, { chatId: "t-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(false);
expect(capture.isFinal).toBe(true);
// The warm step-1 partial is in the accumulator under its messageId.
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-1", role: "assistant", partTypes: ["text"] },
]);
// isFinal means no streamText.
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: false (tool call) → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "custom.handover.toolcall",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: conversation.modelMessages,
tools: { weather: weatherToolWithExecute(toolExecute) },
});
await chat.pipeAndCapture(result);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
// Handover consumed as non-final, and the handed-over tool round resumed:
// the agent-side `execute` ran on the pending tool-call from step 1
// (schema-only-on-warm-handler pattern). The full step-2-text-through-handover
// path is verified end-to-end by the ai-chat-e2e smoke test (T29).
expect(capture.isFinal).toBe(false);
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
it("addResponse replaces the spliced partial in place when the resume reuses its id", async () => {
// On a non-final handover resume the pipe threads originalMessages, so the
// captured response carries the SAME id as the spliced partial. addResponse
// must replace it, not append a duplicate (else the persisted accumulator
// ends up with two assistant messages — caught live by T29, not the mock pipe).
const capture: Capture = {};
const agent = chat.customAgent({
id: "custom.handover.addresponse-dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
// Simulate the merged step-2 response reusing the partial's id.
await conversation.addResponse({
id: "asst-msg-2",
role: "assistant",
parts: [
{ type: "text", text: "the weather in tokyo is 22°C" },
{
type: "tool-weather",
toolCallId: "tc-1",
state: "output-available",
input: { city: "tokyo" },
output: { city: "tokyo", temp: 22 },
} as never,
],
});
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-addresp-dedup", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
await new Promise((r) => setTimeout(r, 20));
// Exactly one assistant message under the handover id — replaced, not doubled.
expect(capture.uiMessages!.filter((m) => m.id === "asst-msg-2")).toHaveLength(1);
expect(capture.uiMessages!.filter((m) => m.role === "assistant")).toHaveLength(1);
// And it carries the merged step-2 content (text + resolved tool output).
expect(capture.uiMessages!.at(-1)?.partTypes).toEqual(["text", "tool-weather"]);
} finally {
await harness.close();
}
});
it("seeds payload.headStartMessages before splicing the partial", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
];
const agent = chat.customAgent({
id: "custom.handover.seed",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-seed",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-3",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Prior history first, then the warm partial.
expect(capture.uiMessages).toEqual([
{ id: "u-1", role: "user", partTypes: ["text"] },
{ id: "asst-msg-3", role: "assistant", partTypes: ["text"] },
]);
} finally {
await harness.close();
}
});
it("dedups the partial when headStartMessages already carries its messageId", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
// Already-persisted partial under the same id the handover uses.
{
id: "asst-dup",
role: "assistant",
parts: [{ type: "text", text: "Hi there, hope you're well." }],
},
];
const agent = chat.customAgent({
id: "custom.handover.dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-dedup",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-dup",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Not doubled — still just the two seeded messages.
expect(capture.uiMessages).toHaveLength(2);
expect(capture.uiMessages!.filter((m) => m.id === "asst-dup")).toHaveLength(1);
} finally {
await harness.close();
}
});
});
// ── chat.createSession + headStart handover ──────────────────────────────
describe("chat.createSession + headStart handover", () => {
it("turn.handover.isFinal: true → complete() with no source finalizes the partial", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "session.handover.final",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
capture.uiMessages = snapshot(turn.uiMessages);
if (turn.handover?.isFinal) {
await turn.complete();
return;
}
runAfter();
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-4",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.handover).toEqual({ isFinal: true });
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-4", role: "assistant", partTypes: ["text"] },
]);
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("turn.handover.isFinal: false → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "session.handover.toolcall",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: turn.messages,
tools: { weather: weatherToolWithExecute(toolExecute) },
abortSignal: turn.signal,
});
await turn.complete(result);
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-5",
isFinal: false,
});
// Surfaced as a non-final handover turn, and the handed-over tool round
// resumed (agent-side execute ran). Full step-2-text path covered by T29.
expect(capture.handover).toEqual({ isFinal: false });
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,123 @@
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import ts from "typescript";
/**
* Regression test for declaration-emit portability (customer TS2742).
*
* Simulates a real consumer: the BUILT package (dist + package.json) is
* copied (not symlinked — tsc's module-specifier generation only uses the
* exports map for files under node_modules real paths) into a temp
* project's node_modules, then a chat-builder agent export is compiled
* with `declaration: true`. Every type appearing in the inferred public
* surface must be nameable through a public package specifier; a type
* declared in a module that isn't reachable via the exports map produces
* a relative-path import in the emit and TS2742 for consumers.
*/
const packageRoot = resolve(__dirname, "..");
const distDir = join(packageRoot, "dist");
const coreRoot = resolve(packageRoot, "../core");
const FIXTURE_SOURCE = `
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import type { UIMessage } from "ai";
import { z } from "zod";
type FixtureUIMessage = UIMessage<never, { kind: { value: string } }>;
export const fixtureAgent = chat
.withUIMessage<FixtureUIMessage>()
.withClientData({ schema: z.object({ userId: z.string() }) })
.agent({
id: "fixture-agent",
run: async ({ messages, signal }) => {
return streamText({ model: "openai/gpt-5" as never, messages, abortSignal: signal });
},
});
`;
describe("declaration emit portability", () => {
it.skipIf(!existsSync(distDir) || !existsSync(join(coreRoot, "dist")))(
"emits portable declarations for inferred chat agent types",
() => {
const consumerDir = mkdtempSync(join(tmpdir(), "sdk-decl-emit-"));
try {
const scopedDir = join(consumerDir, "node_modules", "@trigger.dev");
mkdirSync(scopedDir, { recursive: true });
for (const [name, root] of [
["sdk", packageRoot],
["core", coreRoot],
] as const) {
const target = join(scopedDir, name);
mkdirSync(target, { recursive: true });
cpSync(join(root, "package.json"), join(target, "package.json"));
cpSync(join(root, "dist"), join(target, "dist"), { recursive: true });
}
// Third-party type deps resolve fine through symlinks.
for (const dep of ["ai", "zod", "@ai-sdk/provider"]) {
const source = realpathSync(join(packageRoot, "node_modules", dep));
const target = join(consumerDir, "node_modules", dep);
mkdirSync(resolve(target, ".."), { recursive: true });
symlinkSync(source, target);
}
const fixturePath = join(consumerDir, "agent.ts");
ts.sys.writeFile(fixturePath, FIXTURE_SOURCE);
const emitted = new Map<string, string>();
const host = ts.createCompilerHost({});
host.writeFile = (fileName, text) => emitted.set(fileName, text);
const program = ts.createProgram({
rootNames: [fixturePath],
options: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
strict: true,
declaration: true,
emitDeclarationOnly: true,
skipLibCheck: true,
outDir: join(consumerDir, "out"),
rootDir: consumerDir,
},
host,
});
const emitResult = program.emit();
const diagnostics = [
...ts.getPreEmitDiagnostics(program),
...emitResult.diagnostics,
].filter((d) => d.category === ts.DiagnosticCategory.Error);
const formatted = diagnostics.map((d) =>
ts.flattenDiagnosticMessageText(d.messageText, "\n")
);
expect(formatted).toEqual([]);
const dts = [...emitted.entries()].find(([name]) => name.endsWith("agent.d.ts"))?.[1];
expect(dts).toBeDefined();
// The payload generic must be named via the public subpath, and the
// emit must not fall back to file paths into the package.
expect(dts).toContain('import("@trigger.dev/sdk/chat").ChatTaskWirePayload');
expect(dts).not.toMatch(/import\("\.{1,2}\//);
expect(dts).not.toContain("ai-shared");
} finally {
rmSync(consumerDir, { recursive: true, force: true });
}
}
);
});
@@ -0,0 +1,160 @@
// Plan F.1: pure-function correctness tests for `mergeByIdReplaceWins`,
// the helper that combines `snapshot.messages` with `session.out` replay
// at run boot (plan section B.3). Replay wins on id collision because
// `session.out` carries the freshest representation of an assistant
// message.
import "../src/v3/test/index.js";
import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { __mergeByIdReplaceWinsForTests as mergeByIdReplaceWins } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(id: string, text: string): UIMessage {
return {
id,
role: "user",
parts: [{ type: "text", text }],
};
}
function assistantMessage(id: string, text: string): UIMessage {
return {
id,
role: "assistant",
parts: [{ type: "text", text }],
};
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("mergeByIdReplaceWins", () => {
it("returns a copy of `a` when `b` is empty", () => {
const a = [userMessage("u-1", "hello")];
const result = mergeByIdReplaceWins(a, []);
expect(result).toEqual(a);
// Verify it's a copy (mutating result shouldn't touch a).
result.push(assistantMessage("a-1", "extra"));
expect(a).toHaveLength(1);
});
it("returns a copy of `b` when `a` is empty", () => {
const b = [assistantMessage("a-1", "world")];
const result = mergeByIdReplaceWins([], b);
expect(result).toEqual(b);
result.push(userMessage("u-extra", "extra"));
expect(b).toHaveLength(1);
});
it("returns [] when both inputs are empty", () => {
expect(mergeByIdReplaceWins([], [])).toEqual([]);
});
it("appends fresh ids from `b` after `a`'s entries", () => {
const a = [userMessage("u-1", "hi")];
const b = [assistantMessage("a-1", "ok")];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1"]);
expect(result[0]!.role).toBe("user");
expect(result[1]!.role).toBe("assistant");
});
it("replaces by id when `b` has a colliding entry — replay wins", () => {
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale-version")];
const b = [assistantMessage("a-1", "fresh-version")];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(2);
expect(result[1]!.id).toBe("a-1");
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-version");
});
it("preserves order from `a` even when entries are replaced", () => {
const a = [
userMessage("u-1", "first"),
assistantMessage("a-1", "stale"),
userMessage("u-2", "second"),
assistantMessage("a-2", "also-stale"),
];
const b = [assistantMessage("a-1", "fresh-1"), assistantMessage("a-2", "fresh-2")];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-1");
expect((result[3]!.parts[0] as { text: string }).text).toBe("fresh-2");
});
it("appends `b` entries with no id collision after the merged set", () => {
const a = [userMessage("u-1", "first")];
const b = [
assistantMessage("a-1", "reply-1"),
userMessage("u-2", "second"),
assistantMessage("a-2", "reply-2"),
];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
});
it("treats messages without an id as always-append (no collision possible)", () => {
const a = [
userMessage("u-1", "first"),
// Synthetic message missing the id field — should append, never replace.
{
id: "" as string,
role: "assistant",
parts: [{ type: "text", text: "no-id-a" }],
} as UIMessage,
];
const b = [
{
id: "" as string,
role: "assistant",
parts: [{ type: "text", text: "no-id-b" }],
} as UIMessage,
];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(3);
// Both empty-id messages survive — no merge happens.
const noIdParts = result
.filter((m) => m.id === "")
.map((m) => (m.parts[0] as { text: string }).text);
expect(noIdParts).toEqual(["no-id-a", "no-id-b"]);
});
it("handles consecutive replays of the same id in `b` — last one wins", () => {
// Edge case: `b` has two entries with the same id (shouldn't happen
// for assistants in practice, but the helper must be deterministic).
const a = [assistantMessage("a-1", "v0")];
const b = [assistantMessage("a-1", "v1"), assistantMessage("a-1", "v2")];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(1);
expect((result[0]!.parts[0] as { text: string }).text).toBe("v2");
});
it("preserves user messages (only assistants come from replay) — semantic check", () => {
// The runtime contract: `session.out` contains assistant chunks only,
// so `b` should never contain user messages. If it does (defensively),
// the merge still works — but we lock down the typical pattern here.
const a = [
userMessage("u-1", "first"),
assistantMessage("a-1", "stale"),
userMessage("u-2", "second"),
];
const b = [assistantMessage("a-1", "fresh")];
const result = mergeByIdReplaceWins(a, b);
// User messages from snapshot survive untouched.
expect(result.filter((m) => m.role === "user").map((m) => m.id)).toEqual(["u-1", "u-2"]);
});
it("does not mutate either input array", () => {
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale")];
const b = [assistantMessage("a-1", "fresh"), userMessage("u-2", "next")];
const aSnapshot = JSON.stringify(a);
const bSnapshot = JSON.stringify(b);
mergeByIdReplaceWins(a, b);
expect(JSON.stringify(a)).toBe(aSnapshot);
expect(JSON.stringify(b)).toBe(bSnapshot);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
// Import the test harness FIRST — this installs the resource catalog so
// `chat.agent()` calls below register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { __setSessionOpenImplForTests, sessions } from "../src/v3/sessions.js";
import { apiClientManager, sessionStreams } from "@trigger.dev/core/v3";
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(text: string, id: string) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStreamChunks(text: string): LanguageModelV3StreamPart[] {
return [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
}
/** Model that answers `ANSWER(<last user text>)`, slowly enough that
* records sent right after the turn starts arrive mid-stream. */
function echoModel() {
return new MockLanguageModelV3({
doStream: async ({ prompt }) => {
const users = prompt.filter((m) => m.role === "user");
const last = users[users.length - 1];
const text = Array.isArray(last?.content)
? last.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("")
: "";
return {
stream: simulateReadableStream({
chunks: textStreamChunks(`ANSWER(${text})`),
initialDelayInMs: 100,
chunkDelayInMs: 10,
}),
};
},
});
}
async function waitFor(check: () => boolean, timeoutMs = 10_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error("waitFor timed out");
}
function streamedText(harness: { allChunks: unknown[] }): string {
return (harness.allChunks as { type?: string; delta?: string }[])
.filter((c) => c.type === "text-delta")
.map((c) => c.delta ?? "")
.join("");
}
function turnCompleteCount(harness: { allRawChunks: unknown[] }): number {
return (harness.allRawChunks as { type?: string }[]).filter(
(c) => c.type === "trigger:turn-complete"
).length;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.agent pending wire buffer", () => {
it("dispatches every message buffered during a turn, not just the first", async () => {
const agent = chat.agent({
id: "pending-drain.agent",
run: async ({ messages, signal }) => {
return streamText({ model: echoModel(), messages, abortSignal: signal });
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-1" });
try {
const first = harness.sendMessage(userMessage("m1", "u-1"));
// Once m1's turn is streaming, land two more records back-to-back —
// both are consumed into the turn's buffer before the turn ends.
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
void harness.sendMessage(userMessage("m2", "u-2"));
void harness.sendMessage(userMessage("m3", "u-3"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 3);
const text = streamedText(harness);
const m2At = text.indexOf("ANSWER(m2)");
const m3At = text.indexOf("ANSWER(m3)");
expect(m2At).toBeGreaterThan(-1);
expect(m3At).toBeGreaterThan(-1);
expect(m3At).toBeGreaterThan(m2At);
} finally {
await harness.close();
}
});
});
describe("chat.agent errored turn", () => {
it(
"does not duplicate messages buffered after a turn that threw",
{ timeout: 20000 },
async () => {
// Throw from a pre-stream hook: throws inside the streaming section are
// already covered by its finally, but a hook throw used to leak the
// turn's message handler into the loop-level buffer.
let turnStarts = 0;
const agent = chat.agent({
id: "pending-drain.errored-turn",
onTurnStart: async () => {
turnStarts++;
if (turnStarts === 1) {
throw new Error("synthetic turn failure");
}
},
run: async ({ messages, signal }) => {
return streamText({ model: echoModel(), messages, abortSignal: signal });
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-4" });
try {
// Turn 1 throws — pre-fix its message handler leaked past the turn.
await harness.sendMessage(userMessage("boom", "u-1"));
const second = harness.sendMessage(userMessage("m2", "u-2"));
// m3 lands mid-turn; a leaked handler would push it twice.
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
void harness.sendMessage(userMessage("m3", "u-3"));
await second;
await waitFor(() => streamedText(harness).includes("ANSWER(m3)"));
await new Promise((r) => setTimeout(r, 500));
const text = streamedText(harness);
expect(text.match(/ANSWER\(m3\)/g)).toHaveLength(1);
} finally {
await harness.close();
}
}
);
});
describe("chat.createSession pending wire buffer", () => {
it("dispatches messages buffered during a turn as subsequent turns", async () => {
const agent = chat.customAgent({
id: "pending-drain.session",
run: async (payload) => {
const session = chat.createSession(payload, {
signal: new AbortController().signal,
idleTimeoutInSeconds: 2,
});
for await (const turn of session) {
const result = streamText({
model: echoModel(),
messages: turn.messages,
abortSignal: turn.signal,
});
await turn.complete(result);
}
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-2" });
try {
const first = harness.sendMessage(userMessage("m1", "u-1"));
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
void harness.sendMessage(userMessage("m2", "u-2"));
void harness.sendMessage(userMessage("m3", "u-3"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 3);
const text = streamedText(harness);
expect(text).toContain("ANSWER(m2)");
expect(text).toContain("ANSWER(m3)");
} finally {
await harness.close();
}
});
});
describe("chat.createSession stop + immediate send", () => {
it(
"dispatches a message that arrives right after a stopped turn",
{ timeout: 20000 },
async () => {
const agent = chat.customAgent({
id: "pending-drain.session-stop",
run: async (payload) => {
const session = chat.createSession(payload, {
signal: new AbortController().signal,
idleTimeoutInSeconds: 2,
// Steering config active — the failure mode routed post-stream
// arrivals into the dead steering queue instead of the next turn.
pendingMessages: {},
});
for await (const turn of session) {
const result = streamText({
model: echoModel(),
messages: turn.messages,
abortSignal: turn.signal,
});
await turn.complete(result);
}
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-3" });
try {
const first = harness.sendMessage(userMessage("write a long essay", "u-1"));
await waitFor(() => streamedText(harness).length > 0);
await harness.sendStop();
// Land the next message inside the stopped turn's post-stream window
// (the ~2s totalUsage race), after the abort has settled — previously
// the still-attached handler steering-routed it into the dead queue.
await new Promise((r) => setTimeout(r, 150));
void harness.sendMessage(userMessage("m2", "u-2"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 2);
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
} finally {
await harness.close();
}
}
);
});
describe("session.in.wait() consume cursor", () => {
it("advances lastDispatchedSeqNum alongside lastSeqNum on waitpoint delivery", async () => {
__setSessionOpenImplForTests(undefined);
await runInMockTaskContext(async () => {
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
createSessionStreamWaitpoint: async () => ({ waitpointId: "wp_test_1" }),
waitForWaitpointToken: async () => ({ success: true }),
} as never);
const sessionId = "cursor-sess";
// Simulate records 0..4 already received via SSE before the suspend.
sessionStreams.setLastSeqNum(sessionId, "in", 4);
const result = await sessions.open(sessionId).in.wait();
expect(result.ok).toBe(true);
expect(sessionStreams.lastSeqNum(sessionId, "in")).toBe(5);
// The waitpoint-delivered record was consumed by this caller, so the
// committed-consume cursor (what turn-completes persist as
// `session-in-event-id`) must advance with it.
expect(sessionStreams.lastDispatchedSeqNum(sessionId, "in")).toBe(5);
});
});
});
@@ -0,0 +1,211 @@
// Import the test harness FIRST so the resource catalog is installed
import { mockChatAgent } from "../src/v3/test/index.js";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { describe, expect, it } from "vitest";
import { chat } from "../src/v3/ai.js";
function userMessage(text: string, id?: string) {
return {
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
/** Capture the rendered system message handed to the provider. */
type Captured = { system?: { role: string; content: unknown; providerOptions?: any } };
function makeModel(capture: Captured) {
return new MockLanguageModelV3({
doStream: async (opts) => {
capture.system = opts.prompt.find((m) => m.role === "system") as Captured["system"];
return { stream: textStream("ok") };
},
});
}
/** Poll until the mock model captures the system message (bounded), instead of a fixed sleep. */
async function waitForSystemCaptured(capture: Captured, timeoutMs = 1000, intervalMs = 5) {
const startedAt = Date.now();
while (!capture.system) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("Timed out waiting for system message capture");
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const SYSTEM = "You are a helpful assistant for tests.";
describe("chat prompt caching — system providerOptions", () => {
it("emits a plain system prompt with no providerOptions by default", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.default",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }),
});
const harness = mockChatAgent(agent, { chatId: "pc-default" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.content).toContain("helpful assistant");
expect(cap.system?.providerOptions).toBeUndefined();
} finally {
await harness.close();
}
});
it("attaches cacheControl via the toStreamTextOptions sugar", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.sugar",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-sugar" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.content).toContain("helpful assistant");
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({ type: "ephemeral" });
} finally {
await harness.close();
}
});
it("attaches systemProviderOptions verbatim", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.explicit",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({
systemProviderOptions: {
anthropic: { cacheControl: { type: "ephemeral", ttl: "1h" } },
},
}),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-explicit" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({
type: "ephemeral",
ttl: "1h",
});
} finally {
await harness.close();
}
});
it("carries providerOptions set on chat.prompt.set()", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.prompt-set",
onChatStart: async () => {
chat.prompt.set(SYSTEM, {
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
},
run: async ({ messages, signal }) =>
streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }),
});
const harness = mockChatAgent(agent, { chatId: "pc-prompt-set" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({ type: "ephemeral" });
} finally {
await harness.close();
}
});
it("call-site systemProviderOptions overrides chat.prompt.set providerOptions", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.precedence",
onChatStart: async () => {
chat.prompt.set(SYSTEM, {
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({
systemProviderOptions: {
anthropic: { cacheControl: { type: "ephemeral", ttl: "1h" } },
},
}),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-precedence" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
// The call-site option wins (ttl: "1h"), not the prompt-set default.
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({
type: "ephemeral",
ttl: "1h",
});
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,468 @@
// Import the test harness FIRST — installs the resource catalog so
// `chat.agent()` calls register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { describe, expect, it, vi } from "vitest";
import type { RecoveryBootEvent, RecoveryBootResult } from "../src/v3/ai.js";
import { __setReplaySessionOutTailImplForTests, chat } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(text: string, id = "u-" + Math.random().toString(36).slice(2)) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function assistantMessage(text: string, id = "a-" + Math.random().toString(36).slice(2)) {
return {
id,
role: "assistant" as const,
parts: [{ type: "text" as const, text }],
};
}
function partialAssistantWithToolCall(id: string, toolCallId: string, toolName: string) {
return {
id,
role: "assistant" as const,
parts: [
{
type: `tool-${toolName}` as const,
toolCallId,
state: "input-available" as const,
input: { q: "search" },
},
],
} as unknown as ReturnType<typeof assistantMessage>;
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("onRecoveryBoot — chat.agent recovery hook", () => {
it("does NOT fire on a clean continuation with no recovered state", async () => {
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const agent = chat.agent({
id: "recovery-boot.no-state",
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "no-state",
continuation: true,
previousRunId: "run_prior",
});
try {
// Snapshot is empty, no in-flight users, no partial — guard
// (partialAssistant !== undefined || inFlightUsers.length > 0) is false.
await harness.sendMessage(userMessage("fresh message"));
await new Promise((r) => setTimeout(r, 20));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("fires when there's a partial assistant and surfaces it on the ctx", async () => {
const captured: { event?: RecoveryBootEvent<ReturnType<typeof userMessage>> } = {};
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("recovered") }),
});
const partial = partialAssistantWithToolCall("a-orphan", "tc-1", "search");
const agent = chat.agent({
id: "recovery-boot.partial-fires-hook",
onRecoveryBoot: async (event) => {
captured.event = event as never;
return {};
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "partial-fires-hook",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
try {
await harness.sendMessage(userMessage("next user message"));
await new Promise((r) => setTimeout(r, 20));
expect(captured.event).toBeDefined();
expect(captured.event!.partialAssistant?.id).toBe("a-orphan");
expect(captured.event!.pendingToolCalls).toHaveLength(1);
expect(captured.event!.pendingToolCalls[0]!.toolCallId).toBe("tc-1");
expect(captured.event!.pendingToolCalls[0]!.toolName).toBe("search");
expect(captured.event!.previousRunId).toBe("run_prior");
expect(captured.event!.cause).toBe("unknown");
} finally {
await harness.close();
}
});
it("pendingToolCalls is extracted from the RAW partial (pre-cleanupAbortedParts)", async () => {
// Real-world scenario: cancel-mid-tool-call. Session.out has tool-call
// chunks but the tool never returned. cleanupAbortedParts strips the
// input-available tool part from the partial used for the chain (you
// don't want orphan tool calls poisoning the model context), but
// `pendingToolCalls` should still surface what was happening.
const cleanedPartial = {
id: "a-orphan",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Let me look that up" }],
};
const rawPartial = {
id: "a-orphan",
role: "assistant" as const,
parts: [
{ type: "text" as const, text: "Let me look that up" },
{
type: "tool-search" as const,
toolCallId: "tc-pending",
state: "input-available" as const,
input: { q: "vietnamese pho" },
},
],
} as unknown as typeof cleanedPartial;
const captured: { event?: RecoveryBootEvent } = {};
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.pending-tool-from-raw",
onRecoveryBoot: async (event) => {
captured.event = event;
return {};
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "pending-tool-from-raw",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
// Install AFTER mockChatAgent — its constructor sets its own default
// override that we want to replace for this test.
__setReplaySessionOutTailImplForTests(
async () =>
({
settled: [],
partial: cleanedPartial,
partialRaw: rawPartial,
}) as never
);
try {
await new Promise((r) => setTimeout(r, 50));
expect(captured.event).toBeDefined();
// Cleaned partial → chain (no input-available tool part)
expect(captured.event!.partialAssistant?.parts).toHaveLength(1);
// pendingToolCalls → from raw (input-available tool part visible)
expect(captured.event!.pendingToolCalls).toHaveLength(1);
expect(captured.event!.pendingToolCalls[0]!.toolCallId).toBe("tc-pending");
expect(captured.event!.pendingToolCalls[0]!.toolName).toBe("search");
} finally {
await harness.close();
}
});
it("does NOT fire when there are in-flight users but no partial (graceful exit path)", async () => {
// chat.requestUpgrade(), chat.endRun() before processing, and similar
// graceful exits leave an unacknowledged user on session.in but no
// partial assistant on session.out. That's not recovery — the next
// run just dispatches the message normally.
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered while dead", "u-buffered");
const agent = chat.agent({
id: "recovery-boot.inflight-users-no-partial",
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "inflight-users-no-partial",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("default behavior re-dispatches each in-flight user as a turn", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream(`reply ${turnCount}`) };
},
});
const u1 = userMessage("first buffered", "u-1");
const u2 = userMessage("second buffered", "u-2");
const agent = chat.agent({
id: "recovery-boot.default-dispatch",
// NO onRecoveryBoot — exercise the default path
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "default-dispatch",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
expect(turnCount).toBe(2);
} finally {
await harness.close();
}
});
it("smart default: partial + first user spliced into chain, rest dispatched", async () => {
let observedChain: Array<{ role: string; idHead: string }> = [];
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial answer in progress", "a-partial");
const u1 = userMessage("original question", "u-1");
const u2 = userMessage("follow-up", "u-2");
const agent = chat.agent({
id: "recovery-boot.smart-default",
// NO onRecoveryBoot — exercise the smart default
onTurnStart: async ({ uiMessages }) => {
if (turnCount === 0) {
observedChain = uiMessages.map((m) => ({
role: m.role,
idHead: m.id.slice(0, 10),
}));
}
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "smart-default",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
// Turn 1 fires with the follow-up user (u2). Its chain should
// include [u1 (original), a-partial, u2 (follow-up)].
expect(turnCount).toBe(1);
expect(observedChain.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
expect(observedChain[0]!.idHead).toBe("u-1");
expect(observedChain[1]!.idHead).toBe("a-partial");
expect(observedChain[2]!.idHead).toBe("u-2");
} finally {
await harness.close();
}
});
it("hook's recoveredTurns: [] suppresses re-dispatch of in-flight users", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream(`reply ${turnCount}`) };
},
});
const partial = assistantMessage("partial answer", "a-partial");
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.suppress-dispatch",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({ recoveredTurns: [] }),
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "suppress-dispatch",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never]);
try {
// No turn should fire from the boot-injected queue.
// Send a fresh user message to confirm the agent is alive.
await harness.sendMessage(userMessage("real next message"));
await new Promise((r) => setTimeout(r, 20));
expect(turnCount).toBe(1); // only the explicit sendMessage turn
} finally {
await harness.close();
}
});
it("hook's chain override seeds the accumulator", async () => {
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("acked") }),
});
const custom = assistantMessage("custom-recovered-history", "a-custom");
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered", "u-1");
let observedMessageCount = 0;
const agent = chat.agent({
id: "recovery-boot.chain-override",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({
chain: [custom as never],
recoveredTurns: [u1 as never],
}),
onTurnStart: async ({ uiMessages }) => {
observedMessageCount = uiMessages.length;
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "chain-override",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
// Chain seeded with [custom] before the recovered user message
// arrives — onTurnStart sees [custom, u1] when the first
// recovered turn fires.
expect(observedMessageCount).toBe(2);
} finally {
await harness.close();
}
});
it("does NOT fire when hydrateMessages is registered", async () => {
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.hydrate-skips",
hydrateMessages: async ({ incomingMessages }) => incomingMessages,
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "hydrate-skips",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
try {
await harness.sendMessage(userMessage("fresh"));
await new Promise((r) => setTimeout(r, 20));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("beforeBoot runs before the first recovered turn fires", async () => {
const order: string[] = [];
const model = new MockLanguageModelV3({
doStream: async () => {
order.push("turn");
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered original", "u-1");
const u2 = userMessage("followup", "u-2");
const agent = chat.agent({
id: "recovery-boot.before-boot",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({
beforeBoot: async () => {
order.push("beforeBoot");
},
}),
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "before-boot",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
// Two users — smart default consumes u1 into the chain, leaves u2 for dispatch
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
expect(order).toEqual(["beforeBoot", "turn"]);
} finally {
await harness.close();
}
});
it("hook throwing falls back to defaults without sinking the run", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered original", "u-1");
const u2 = userMessage("followup", "u-2");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const agent = chat.agent({
id: "recovery-boot.hook-throws",
onRecoveryBoot: async () => {
throw new Error("kaboom");
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "hook-throws",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
// Two users so smart default leaves u2 to dispatch (u1 spliced into chain)
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
// Default behavior: the in-flight user is re-dispatched as a turn
// even though the hook threw.
expect(turnCount).toBe(1);
} finally {
await harness.close();
warnSpy.mockRestore();
}
});
});
@@ -0,0 +1,225 @@
// Import the test entry point first so the resource catalog is installed.
import "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__findLatestSessionInCursorForTests as findLatestSessionInCursor,
__replaySessionInTailProductionPathForTests as replaySessionInTail,
} from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(id: string, text: string) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function stubReadRecords(chunks: unknown[]) {
const records = chunks.map((chunk, i) => ({
data: chunk,
id: `evt-${i + 1}`,
seqNum: i + 1,
}));
const spy = vi.fn(async () => ({ records }));
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: spy,
} as never);
return spy;
}
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Tests ──────────────────────────────────────────────────────────────
describe("replaySessionInTail", () => {
it("extracts user messages from kind: 'message' records with submit-message trigger", async () => {
const u1 = userMessage("u-1", "hello");
const u2 = userMessage("u-2", "again");
stubReadRecords([
{
kind: "message",
payload: {
chatId: "c1",
trigger: "submit-message",
message: u1,
metadata: { userId: "a" },
},
},
{
kind: "message",
payload: {
chatId: "c1",
trigger: "submit-message",
message: u2,
metadata: { userId: "b" },
},
},
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(2);
expect(result[0]!.message.id).toBe("u-1");
expect(result[0]!.seqNum).toBe(1);
expect(result[0]!.metadata).toEqual({ userId: "a" });
expect(result[1]!.message.id).toBe("u-2");
expect(result[1]!.seqNum).toBe(2);
expect(result[1]!.metadata).toEqual({ userId: "b" });
});
it("ignores non-message variants (stop, handover, handover-skip)", async () => {
const u1 = userMessage("u-1", "real user");
stubReadRecords([
{ kind: "stop", message: "user stopped" },
{ kind: "handover-skip" },
{ kind: "handover", partialAssistantMessage: [], isFinal: false },
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: u1 } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(1);
expect(result[0]!.message.id).toBe("u-1");
});
it("ignores message records that aren't submit-message", async () => {
// regenerate-message / preload / close / action / handover-prepare don't
// carry a user message — the chain reconstruction must skip them.
stubReadRecords([
{ kind: "message", payload: { chatId: "c1", trigger: "regenerate-message" } },
{ kind: "message", payload: { chatId: "c1", trigger: "preload" } },
{ kind: "message", payload: { chatId: "c1", trigger: "close" } },
{ kind: "message", payload: { chatId: "c1", trigger: "action", action: { foo: 1 } } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(0);
});
it("ignores records whose payload is missing or empty", async () => {
stubReadRecords([
{ kind: "message" }, // no payload
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message" } }, // no message
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: null } },
{
kind: "message",
payload: { chatId: "c1", trigger: "submit-message", message: "not-an-object" },
},
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(0);
});
it("skips non-object record data defensively", async () => {
const u1 = userMessage("u-1", "valid");
stubReadRecords([
42,
null,
"string-data",
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: u1 } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(1);
expect(result[0]!.message.id).toBe("u-1");
});
it("passes the afterEventId cursor through to readSessionStreamRecords", async () => {
const spy = stubReadRecords([]);
await replaySessionInTail("sess", { lastEventId: "evt-42" });
expect(spy).toHaveBeenCalledWith("sess", "in", { afterEventId: "evt-42" });
});
it("returns an empty list when the records endpoint returns no records", async () => {
stubReadRecords([]);
const result = await replaySessionInTail("sess");
expect(result).toEqual([]);
});
});
// ── Cursor scan (non-blocking records read, not an SSE drain) ──────────
function stubReadRecordsWithHeaders(
records: Array<{ data?: unknown; headers?: Array<[string, string]> }>
) {
const shaped = records.map((r, i) => ({
data: r.data ?? "",
id: `evt-${i + 1}`,
seqNum: i + 1,
headers: r.headers,
}));
const spy = vi.fn(async () => ({ records: shaped }));
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: spy,
} as never);
return spy;
}
describe("findLatestSessionInCursor", () => {
it("returns the LAST turn-complete's session-in-event-id", async () => {
const spy = stubReadRecordsWithHeaders([
{ data: { type: "text-delta", delta: "hi" } },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "3"],
],
},
{ data: { type: "text-delta", delta: "again" } },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "7"],
],
},
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBe(7);
// Non-blocking records read on `.out`, no SSE subscribe.
expect(spy).toHaveBeenCalledWith("sess", "out");
});
it("ignores other control subtypes and turn-completes without the header", async () => {
stubReadRecordsWithHeaders([
{
headers: [
["trigger-control", "upgrade-required"],
["session-in-event-id", "99"],
],
},
{ headers: [["trigger-control", "turn-complete"]] },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "4"],
],
},
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBe(4);
});
it("returns undefined when records carry no headers (older server)", async () => {
stubReadRecordsWithHeaders([
{ data: { type: "text-delta", delta: "hi" } },
{ data: { type: "finish" } },
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBeUndefined();
});
});
@@ -0,0 +1,275 @@
// Import the test entry point first so the resource catalog is installed.
import "../src/v3/test/index.js";
import type { UIMessageChunk } from "ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import { __replaySessionOutTailProductionPathForTests as replaySessionOutTail } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Build the canonical chunk sequence the AI SDK emits for a single text
* turn from message `id`. Includes a trailing `finish` so the segment is
* marked closed (i.e. NOT subject to `cleanupAbortedParts`).
*/
function textTurn(id: string, text: string, role: "assistant" = "assistant"): UIMessageChunk[] {
return [
{ type: "start", messageId: id, messageMetadata: { role } } as UIMessageChunk,
{ type: "text-start", id: `${id}.t1` } as UIMessageChunk,
{ type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk,
{ type: "text-end", id: `${id}.t1` } as UIMessageChunk,
{ type: "finish" } as UIMessageChunk,
];
}
/**
* Stub `apiClientManager.clientOrThrow().readSessionStreamRecords` so the
* helper sees a `{ records: StreamRecord[] }` response. Each StreamRecord
* is `{ data, id, seqNum }` — `data` is the parsed chunk OBJECT (the wire
* writer puts chunks directly into the record envelope; the route
* forwards them as-is; the schema declares `data: z.unknown()`).
*
* Pass either a chunk OBJECT (used as `data` directly) or a string
* (used as `data` directly — for tests that need deliberately-malformed
* bodies; the consumer filters non-objects out).
*
* Captures the `afterEventId` argument for resume-from-cursor assertions.
*/
function stubReadRecordsWithChunks(chunks: unknown[]) {
const records = chunks.map((chunk, i) => ({
data: chunk,
id: `evt-${i + 1}`,
seqNum: i + 1,
}));
const readRecordsSpy = vi.fn(
async (_id: string, _io: "in" | "out", _options?: { afterEventId?: string }) => ({
records,
})
);
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: readRecordsSpy,
} as never);
return readRecordsSpy;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("replaySessionOutTail", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
warnSpy.mockRestore();
});
it("returns [] for an empty session.out stream", async () => {
stubReadRecordsWithChunks([]);
const result = await replaySessionOutTail("empty-session");
expect(result).toEqual([]);
});
it("reduces a single text turn into one assistant UIMessage", async () => {
stubReadRecordsWithChunks(textTurn("a-1", "hello world"));
const result = await replaySessionOutTail("text-session");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ id: "a-1", role: "assistant" });
const text = (result[0]!.parts as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
expect(text).toBe("hello world");
});
it("reduces multiple sequential turns into multiple UIMessages", async () => {
stubReadRecordsWithChunks([
...textTurn("a-1", "first"),
...textTurn("a-2", "second"),
...textTurn("a-3", "third"),
]);
const result = await replaySessionOutTail("multi-session");
expect(result).toHaveLength(3);
expect(result.map((m) => m.id)).toEqual(["a-1", "a-2", "a-3"]);
});
it("filters out `trigger:*` control chunks (turn-complete, etc.)", async () => {
stubReadRecordsWithChunks([
...textTurn("a-1", "hello"),
{ type: "trigger:turn-complete", lastEventId: "evt-1", lastEventTimestamp: 1 },
{ type: "trigger:upgrade-required" },
...textTurn("a-2", "second"),
]);
const result = await replaySessionOutTail("control-session");
// Two assistant messages reduced — the trigger:* records are dropped
// before reaching the reducer.
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toEqual(["a-1", "a-2"]);
});
it("never emits user-role messages (session.out is assistant-only)", async () => {
// session.out conceptually only carries assistant chunks (the user's
// messages live on session.in). Even if a user-role start somehow
// landed there, the reducer wouldn't surface a user message via this
// helper's contract.
stubReadRecordsWithChunks(textTurn("a-1", "ok"));
const result = await replaySessionOutTail("assistant-only");
expect(result.every((m) => m.role !== "user")).toBe(true);
});
it("passes `lastEventId` through as `afterEventId` to readSessionStreamRecords", async () => {
// The replay helper accepts `lastEventId` from the caller (matching
// the snapshot's persisted cursor name) and forwards it as
// `afterEventId` on the records endpoint — that's the field name on
// the new non-SSE route.
const readRecordsSpy = stubReadRecordsWithChunks(textTurn("a-1", "ok"));
await replaySessionOutTail("resume-session", { lastEventId: "evt-99" });
expect(readRecordsSpy).toHaveBeenCalledWith(
"resume-session",
"out",
expect.objectContaining({ afterEventId: "evt-99" })
);
});
it("uses the non-SSE records endpoint (drain-and-close, no long-poll)", async () => {
// Replay no longer subscribes to the SSE stream — that imposed a ~1s
// long-poll tax on every fresh chat boot. The new path hits
// `readSessionStreamRecords` (one synchronous GET that returns
// whatever's already in the stream) and returns immediately when
// empty. Lock the call site down so a regression to SSE shows up
// here.
const readRecordsSpy = stubReadRecordsWithChunks([]);
const result = await replaySessionOutTail("drain-session");
expect(readRecordsSpy).toHaveBeenCalledWith("drain-session", "out", expect.any(Object));
expect(result).toEqual([]);
});
it("strips orphaned in-flight tool parts from a partial trailing assistant", async () => {
// The runtime applies `cleanupAbortedParts` only on the trailing
// segment when its closure flag is `false` (no `finish` chunk
// received). The cleanup removes tool parts that never reached a
// terminal state — `input-streaming`, `output-pending`, etc. —
// because those represent partial in-flight work that won't resolve.
//
// Text parts with already-streamed content are preserved (the user
// already saw them), so we test the tool-part path specifically.
stubReadRecordsWithChunks([
...textTurn("a-1", "previous-turn-finished"),
// Trailing turn: starts a tool call but never resolves it.
{ type: "start", messageId: "a-2", messageMetadata: { role: "assistant" } } as UIMessageChunk,
{ type: "tool-input-start", toolCallId: "tc-cut", toolName: "search" } as UIMessageChunk,
{
type: "tool-input-delta",
toolCallId: "tc-cut",
inputTextDelta: '{"q":"x"}',
} as UIMessageChunk,
// No tool-input-end, no tool-call, no finish → orphaned.
]);
const result = await replaySessionOutTail("partial-tool-session");
// The closed turn survives.
expect(result.find((m) => m.id === "a-1")).toBeTruthy();
// Trailing message either gets dropped (cleanup empties it) or its
// orphaned tool part is stripped to a terminal state. Either way,
// no `tc-cut` part should be left in `input-streaming` state — that
// would represent a tool the next turn would re-process.
const trailing = result.find((m) => m.id === "a-2");
if (trailing) {
const orphanedToolPart = (
trailing.parts as Array<{ type: string; toolCallId?: string; state?: string }>
).find((p) => p.toolCallId === "tc-cut" && p.state === "input-streaming");
expect(orphanedToolPart).toBeUndefined();
}
});
it("drops a trailing message whose only parts are stripped by cleanup", async () => {
// Trailing turn whose ONLY content is an orphaned tool — after
// cleanup the message has no parts left, so the helper drops it
// entirely (it never reached the next turn's accumulator).
stubReadRecordsWithChunks([
...textTurn("a-1", "complete"),
{
type: "start",
messageId: "a-orphan",
messageMetadata: { role: "assistant" },
} as UIMessageChunk,
{ type: "tool-input-start", toolCallId: "tc-orph", toolName: "search" } as UIMessageChunk,
// No tool-input-end, no tool-call, no finish.
]);
const result = await replaySessionOutTail("dropped-trailing");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("preserves a complete trailing assistant (cleanup is a no-op)", async () => {
// Trailing turn that DID end with `finish` is closed — cleanupAbortedParts
// doesn't fire. Use this to lock down that closed segments survive
// unchanged.
stubReadRecordsWithChunks(textTurn("a-1", "fully-finished"));
const result = await replaySessionOutTail("closed-session");
expect(result).toHaveLength(1);
const text = (result[0]!.parts as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
expect(text).toBe("fully-finished");
});
it("skips records whose data is a string (the wire delivers objects)", async () => {
// The writer puts chunk objects directly into the record envelope;
// the route forwards them as-is. A string body is malformed — the
// consumer drops it defensively rather than JSON.parsing.
stubReadRecordsWithChunks(["not-an-object", ...textTurn("a-1", "survived")]);
const result = await replaySessionOutTail("garbage-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("skips records whose data is not an object", async () => {
// The consumer requires `chunk` to be a non-null object with a
// string `type` field. Records that arrive as primitives
// (number, null, string) are dropped silently.
stubReadRecordsWithChunks([42, null, "just-a-string", ...textTurn("a-1", "survived")]);
const result = await replaySessionOutTail("primitive-data-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("ignores chunks missing a `type` field", async () => {
stubReadRecordsWithChunks([{ foo: "bar" }, { type: 42 }, ...textTurn("a-1", "valid")]);
const result = await replaySessionOutTail("typeless-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("recovers from a malformed segment by skipping it (logs a warn)", async () => {
// The reducer for one segment throws (e.g. invalid chunk sequence).
// The helper logs the warning and proceeds with the next segment —
// a single corrupt segment shouldn't sink the entire replay.
stubReadRecordsWithChunks([
// Malformed: text-end with no preceding text-start.
{
type: "start",
messageId: "bad-1",
messageMetadata: { role: "assistant" },
} as UIMessageChunk,
{ type: "text-end", id: "no-such-text" } as UIMessageChunk,
{ type: "finish" } as UIMessageChunk,
...textTurn("a-1", "after-bad"),
]);
const result = await replaySessionOutTail("recovery-session");
// The valid turn after the malformed one must still surface.
expect(result.find((m) => m.id === "a-1")).toBeTruthy();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { defineSkill, parseFrontmatter } from "../src/v3/skill.js";
describe("parseFrontmatter", () => {
it("parses name + description", () => {
const { frontmatter, body } = parseFrontmatter(
`---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n`
);
expect(frontmatter.name).toBe("pdf-processing");
expect(frontmatter.description).toBe("Extract text from PDFs.");
expect(body).toBe("# Body\n\nhello\n");
});
it("strips surrounding quotes", () => {
const { frontmatter } = parseFrontmatter(
`---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n`
);
expect(frontmatter.name).toBe("quoted-name");
expect(frontmatter.description).toBe("single quoted");
});
it("throws on missing frontmatter block", () => {
expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/);
});
it("throws on missing required name", () => {
expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow(
/missing required `name`/
);
});
it("throws on missing required description", () => {
expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow(
/missing required `description`/
);
});
});
describe("defineSkill.local()", () => {
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-")));
process.chdir(workdir);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => {
const skillDir = path.join(workdir, ".trigger", "skills", "pdf");
await mkdir(skillDir, { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n`
);
const skill = defineSkill({ id: "pdf", path: "./skills/pdf" });
const resolved = await skill.local();
expect(resolved.id).toBe("pdf");
expect(resolved.version).toBe("local");
expect(resolved.labels).toEqual([]);
expect(resolved.frontmatter.name).toBe("pdf");
expect(resolved.frontmatter.description).toBe("Extract PDF text.");
expect(resolved.body).toContain("# PDF skill");
expect(resolved.body).toContain("Use scripts/extract.py");
expect(resolved.path).toBe(skillDir);
});
it("throws a useful error when SKILL.md is missing", async () => {
const skill = defineSkill({ id: "missing", path: "./skills/missing" });
await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/);
});
it("resolve() throws with a helpful Phase 1 message", async () => {
const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" });
await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s);
});
});
@@ -0,0 +1,221 @@
// Import the test harness FIRST so the resource catalog is installed
import { mockChatAgent } from "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { MockLanguageModelV3 } from "ai/test";
import { simulateReadableStream, streamText } from "ai";
import { buildSkillTools, chat } from "../src/v3/ai.js";
import { defineSkill } from "../src/v3/skill.js";
function userMessage(text: string, id?: string) {
return {
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-")));
process.chdir(workdir);
// Bundled skill layout
const skillDir = path.join(workdir, ".trigger", "skills", "demo");
await mkdir(path.join(skillDir, "scripts"), { recursive: true });
await mkdir(path.join(skillDir, "references"), { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n`
);
const scriptPath = path.join(skillDir, "scripts", "hello.sh");
await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`);
await chmod(scriptPath, 0o755);
await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n");
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
describe("chat.skills runtime integration", () => {
it("injects skills preamble into the system prompt", async () => {
let capturedSystem: string | undefined;
const model = new MockLanguageModelV3({
doStream: async (opts) => {
const system = opts.prompt.find((m) => m.role === "system");
capturedSystem = system ? JSON.stringify(system.content) : undefined;
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.system-prompt",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t1" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedSystem).toContain("Available skills");
expect(capturedSystem).toContain("demo: Demo skill for tests");
} finally {
await harness.close();
}
});
it("auto-wires loadSkill / readFile / bash tools", async () => {
let capturedToolNames: string[] = [];
const model = new MockLanguageModelV3({
doStream: async (opts) => {
capturedToolNames = (opts.tools ?? []).map((t) => t.name);
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.auto-tools",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t2" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"]));
} finally {
await harness.close();
}
});
});
describe("buildSkillTools — direct execute", () => {
it("loadSkill returns body + path for a known skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const resolved = await skill.local();
const tools = buildSkillTools([resolved]);
const out = await (tools.loadSkill as any).execute({ name: "demo" });
expect(out.name).toBe("demo");
expect(out.body).toContain("# Demo");
expect(out.path).toBe(resolved.path);
});
it("loadSkill returns an error for an unknown skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.loadSkill as any).execute({ name: "missing" });
expect(out.error).toContain('Skill "missing" not found');
});
it("readFile reads a bundled reference", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "references/notes.txt",
});
expect(out.content).toBe("Reference note.\n");
});
it("readFile rejects path traversal", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "../../../../etc/passwd",
});
expect(out.error).toMatch(/escapes the skill directory/);
});
it("readFile rejects absolute paths", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "/etc/passwd",
});
expect(out.error).toMatch(/must be relative/);
});
it("bash runs a bundled script and captures stdout", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "bash scripts/hello.sh world",
});
expect(out.exitCode).toBe(0);
expect(out.stdout).toContain("hi from world");
});
it("bash reports non-zero exit code", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "exit 7",
});
expect(out.exitCode).toBe(7);
});
});
@@ -0,0 +1,545 @@
// The slim wire payload shape is the contract between the transport
// (`TriggerChatTransport.sendMessages` etc.) and the agent runtime. This
// test locks the shape down at the type and JSON-roundtrip level so a
// future change either holds the wire stable or breaks loudly.
//
// Plan F.1: verify `messages` is gone, `message`/`headStartMessages` are
// typed correctly. See plan section A.1.
import "../src/v3/test/index.js";
import type { UIMessage } from "ai";
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ChatInputChunk, ChatTaskWirePayload } from "../src/v3/ai-shared.js";
import { slimSubmitMessageForWire, upsertIncomingMessage } from "../src/v3/ai-shared.js";
describe("ChatTaskWirePayload (slim wire shape)", () => {
it("encodes and decodes a submit-message payload through JSON", () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hi" }],
};
const wire: ChatTaskWirePayload = {
message: userMsg,
chatId: "chat-1",
trigger: "submit-message",
metadata: { userId: "u-1" },
};
const encoded = JSON.stringify(wire);
const decoded = JSON.parse(encoded) as ChatTaskWirePayload;
expect(decoded).toEqual(wire);
expect(decoded.message).toEqual(userMsg);
expect(decoded.trigger).toBe("submit-message");
});
it("encodes and decodes a regenerate-message payload (no message body)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "regenerate-message",
metadata: undefined,
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("regenerate-message");
expect(decoded.message).toBeUndefined();
expect(decoded.headStartMessages).toBeUndefined();
});
it("encodes and decodes a handover-prepare payload with headStartMessages", () => {
const history: UIMessage[] = [
{
id: "u-1",
role: "user",
parts: [{ type: "text", text: "first" }],
},
{
id: "a-1",
role: "assistant",
parts: [{ type: "text", text: "ok" }],
},
];
const wire: ChatTaskWirePayload = {
headStartMessages: history,
chatId: "chat-1",
trigger: "handover-prepare",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.headStartMessages).toEqual(history);
expect(decoded.message).toBeUndefined();
});
it("encodes and decodes a preload payload (no message, no headStartMessages)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "preload",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("preload");
expect(decoded.message).toBeUndefined();
expect(decoded.headStartMessages).toBeUndefined();
});
it("encodes and decodes a close payload", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "close",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("close");
});
it("encodes and decodes an action payload (carries `action`, no message)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "action",
action: { type: "undo" },
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("action");
expect(decoded.action).toEqual({ type: "undo" });
expect(decoded.message).toBeUndefined();
});
it("preserves continuation / previousRunId / sessionId across the wire", () => {
const wire: ChatTaskWirePayload = {
message: {
id: "u-2",
role: "user",
parts: [{ type: "text", text: "continued" }],
},
chatId: "chat-1",
trigger: "submit-message",
continuation: true,
previousRunId: "run_abc",
sessionId: "sess_xyz",
idleTimeoutInSeconds: 42,
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.continuation).toBe(true);
expect(decoded.previousRunId).toBe("run_abc");
expect(decoded.sessionId).toBe("sess_xyz");
expect(decoded.idleTimeoutInSeconds).toBe(42);
});
it("preserves a tool-approval-responded assistant message in `message`", () => {
// The HITL slim-wire path sends an assistant message with
// `state: "approval-responded"` tool parts in `message`, not the
// full chain. The agent merges by id.
const approvalMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-42",
state: "output-available",
input: { q: "x" },
output: { hits: 7 },
} as never,
],
};
const wire: ChatTaskWirePayload = {
message: approvalMsg,
chatId: "chat-1",
trigger: "submit-message",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.message).toEqual(approvalMsg);
});
});
describe("upsertIncomingMessage", () => {
const userMsg = (id: string, text: string): UIMessage => ({
id,
role: "user",
parts: [{ type: "text", text }],
});
it("pushes a fresh user message and returns true", () => {
const stored: UIMessage[] = [userMsg("u-1", "first")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [userMsg("u-2", "second")],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
expect(stored[1]!.id).toBe("u-2");
});
it("no-ops when incoming id is already in stored (HITL continuation)", () => {
const head = {
id: "asst-1",
role: "assistant" as const,
parts: [
{ type: "tool-search", toolCallId: "tc-1", state: "input-available", input: {} } as never,
],
};
const stored: UIMessage[] = [userMsg("u-1", "hi"), head];
const slim = {
id: "asst-1",
role: "assistant" as const,
parts: [
{ type: "tool-search", toolCallId: "tc-1", state: "output-available", output: {} } as never,
],
};
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [slim],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(2);
// The original head is untouched — the runtime's per-turn merge
// overlays the resolution; the customer's stored array is just
// the pre-merge snapshot.
expect(stored[1]).toBe(head);
});
it("no-ops on regenerate-message trigger", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "regenerate-message",
incomingMessages: [userMsg("u-2", "ignored")],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("no-ops on action trigger", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "action",
incomingMessages: [],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("no-ops on empty incomingMessages", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("only inspects the last incoming message (slim wire ships at most one)", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [userMsg("ignored", "ignored"), userMsg("u-3", "new")],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
expect(stored[1]!.id).toBe("u-3");
});
it("pushes when newMsg has no id (no dedup possible)", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const incoming = {
role: "user",
parts: [{ type: "text", text: "no id" }],
} as unknown as UIMessage;
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [incoming],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
});
it("accepts the full hydrateMessages event without re-packaging", () => {
// Customers can pass the destructured event directly — the helper
// only reads `trigger` + `incomingMessages` but ignores any other
// fields the event happens to carry.
const stored: UIMessage[] = [];
const event = {
chatId: "chat-1",
turn: 0,
trigger: "submit-message" as const,
incomingMessages: [userMsg("u-1", "hi")],
previousMessages: [],
continuation: false,
};
const mutated = upsertIncomingMessage(stored, event);
expect(mutated).toBe(true);
expect(stored).toHaveLength(1);
});
});
describe("slimSubmitMessageForWire", () => {
it("passes user messages through unchanged", () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
};
expect(slimSubmitMessageForWire(userMsg)).toBe(userMsg);
});
it("passes assistant messages with no resolved tool parts through unchanged", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "thinking..." },
{
type: "tool-search",
toolCallId: "tc-1",
state: "input-available",
input: { q: "x" },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toBe(assistantMsg);
});
it("slims output-available HITL continuation to {type, toolCallId, state, output}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "let me search" },
{ type: "reasoning", text: "long reasoning blob..." } as never,
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-available",
input: { q: "very long query".repeat(1000) },
output: { hits: 7 },
} as never,
],
};
const slim = slimSubmitMessageForWire(assistantMsg);
expect(slim).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-available",
output: { hits: 7 },
},
],
});
// The slim drops `input` (server has it via hydrate/snapshot) — the
// wire is much smaller than the original.
expect(JSON.stringify(slim).length).toBeLessThan(JSON.stringify(assistantMsg).length / 50);
});
it("slims output-error to {type, toolCallId, state, errorText}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-error",
input: { q: "x" },
errorText: "boom",
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-error",
errorText: "boom",
},
],
});
});
it("slims approval-responded to {type, toolCallId, state, approval}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-delete",
toolCallId: "tc-1",
state: "approval-responded",
input: { path: "/critical" },
approval: { id: "appr_1", approved: true, reason: "looks fine" },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-delete",
toolCallId: "tc-1",
state: "approval-responded",
approval: { id: "appr_1", approved: true, reason: "looks fine" },
},
],
});
});
it("slims dynamic-tool parts and preserves toolName", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: "dyn-search",
toolCallId: "tc-1",
state: "output-available",
input: { q: "x" },
output: { hits: 1 },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: "dyn-search",
toolCallId: "tc-1",
state: "output-available",
output: { hits: 1 },
},
],
});
});
it("only slims the advanced tool parts when an assistant has mixed states", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "thinking" },
{
type: "tool-search",
toolCallId: "tc-resolved",
state: "output-available",
input: { q: "x" },
output: { hits: 1 },
} as never,
{
type: "tool-askUser",
toolCallId: "tc-still-pending",
state: "input-available",
input: { q: "ok?" },
} as never,
],
};
const slim = slimSubmitMessageForWire(assistantMsg);
expect(slim?.parts).toHaveLength(1);
expect((slim!.parts[0] as any).toolCallId).toBe("tc-resolved");
});
it("handles undefined input", () => {
expect(slimSubmitMessageForWire(undefined)).toBeUndefined();
});
});
describe("ChatTaskWirePayload (compile-time shape)", () => {
it("does NOT have a `messages` array field (slim wire removed it)", () => {
// If a future edit reintroduces `messages: TMessage[]`, this assertion
// forces a compile error rather than letting the wire silently grow
// back.
type WirePayloadKeys = keyof ChatTaskWirePayload;
expectTypeOf<WirePayloadKeys>().not.toEqualTypeOf<
"messages" | Exclude<WirePayloadKeys, "messages">
>();
// Also confirm the absence at the value level — a payload literal
// with `messages` would be a TS error if uncommented:
//
// const bad: ChatTaskWirePayload = { messages: [], chatId: "x", trigger: "submit-message" };
//
// Leaving as a comment for clarity; the type assertion above is the
// load-bearing check.
});
it("has `message?: UIMessage` (singular, optional)", () => {
expectTypeOf<ChatTaskWirePayload["message"]>().toEqualTypeOf<UIMessage | undefined>();
});
it("has `headStartMessages?: UIMessage[]` (escape hatch)", () => {
expectTypeOf<ChatTaskWirePayload["headStartMessages"]>().toEqualTypeOf<
UIMessage[] | undefined
>();
});
it("requires `chatId: string` and `trigger: <one of>`", () => {
expectTypeOf<ChatTaskWirePayload["chatId"]>().toEqualTypeOf<string>();
expectTypeOf<ChatTaskWirePayload["trigger"]>().toEqualTypeOf<
"submit-message" | "regenerate-message" | "preload" | "close" | "action" | "handover-prepare"
>();
});
});
describe("ChatInputChunk envelope", () => {
it('wraps a wire payload in `kind: "message"` shape', () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
};
const chunk: ChatInputChunk = {
kind: "message",
payload: {
message: userMsg,
chatId: "chat-1",
trigger: "submit-message",
},
};
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("message");
if (decoded.kind === "message") {
expect(decoded.payload.message).toEqual(userMsg);
}
});
it('supports `kind: "stop"` records (no payload)', () => {
const chunk: ChatInputChunk = { kind: "stop", message: "user-canceled" };
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("stop");
if (decoded.kind === "stop") {
expect(decoded.message).toBe("user-canceled");
}
});
it('supports `kind: "handover"` records (with partialAssistantMessage)', () => {
const chunk: ChatInputChunk = {
kind: "handover",
partialAssistantMessage: [
{ role: "assistant", content: [{ type: "text", text: "partial" }] },
],
messageId: "a-1",
isFinal: false,
};
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("handover");
});
it('supports `kind: "handover-skip"` records', () => {
const chunk: ChatInputChunk = { kind: "handover-skip" };
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("handover-skip");
});
});
+21
View File
@@ -0,0 +1,21 @@
{
// Typechecks the SDK source against AI SDK 7 (the `ai-v7` aliased devDep)
// instead of the v6 build devDep, so CI catches any internal source that only
// compiles against one major. The published dist is still built once against
// v6 — `ai` types in the `.d.ts` resolve to each consumer's own version.
//
// `paths` points at the `.d.ts` file directly (not the package dir): under
// `moduleResolution: NodeNext` the package-dir form silently falls back to the
// real installed `ai`, which would make this pass a no-op.
"extends": "./tsconfig.json",
"compilerOptions": {
// noEmit-only gate: disable composite/incremental so this pass never writes or
// reads a .tsbuildinfo and can't replay stale module resolution across runs.
"composite": false,
"incremental": false,
"baseUrl": ".",
"paths": {
"ai": ["./node_modules/ai-v7/dist/index.d.ts"]
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../.configs/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"sourceMap": true,
"stripInternal": true,
"isolatedDeclarations": false
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../.configs/tsconfig.base.json",
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true,
"stripInternal": true
},
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts", "./test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts", "src/v3/**/*.test.ts"],
globals: true,
},
});