chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
[](https://www.npmjs.com/package/@trigger.dev/sdk)
|
||||
[](https://www.npmjs.com/package/@trigger.dev/sdk)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 don’t 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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type Locals, locals, type LocalsKey } from "@trigger.dev/core/v3";
|
||||
|
||||
export type { Locals, LocalsKey };
|
||||
|
||||
export { locals };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.`
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../.configs/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"stripInternal": true,
|
||||
"isolatedDeclarations": false
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts", "src/v3/**/*.test.ts"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user