feat(sdk): chat.agent — runtime + browser transport
Adds the chat.agent({...}) task definition (server runtime) and the
browser-side TriggerChatTransport + AgentChat that drives it from a
React or Next.js app. The runtime sits on top of the Sessions primitive
and handles the durable conversational task lifecycle.
Server runtime:
- chat.agent({...}) — session-aware task definition
- Lifecycle hooks: onChatStart, onTurnStart, onTurnComplete, onAction,
onValidateMessages, hydrateMessages
- chat.history read primitives for HITL flows
- chat.local, chat.headStart, chat.handover, oomMachine
- Delta-only wire + S3 snapshot reconstruction at run boot
- Actions are no longer turns
Browser transport:
- TriggerChatTransport (ai-sdk Transport): delta-only wire sends,
SSE reconnection with lastEventId resume, stop/abort cleanup,
dynamic accessToken refresh
- AgentChat: direct programmatic API
- useTriggerChatTransport (React hook)
- chat-tab-coordinator: cross-tab leader election
Includes the chat-agent, chat-agent-delta-wire-snapshots,
chat-history-read-primitives, chat-head-start, chat-actions-no-turn,
chat-session-attributes, agent-skills, and mock-chat-agent-test-harness
changesets.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/build": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add Agent Skills for `chat.agent`. Drop a folder with a `SKILL.md` and any helper scripts/references next to your task code, register it with `skills.define({ id, path })`, and the CLI bundles it into the deploy image automatically — no `trigger.config.ts` changes. The agent gets a one-line summary in its system prompt and discovers full instructions on demand via `loadSkill`, with `bash` and `readFile` tools scoped per-skill (path-traversal guards, output caps, abort-signal propagation).
|
||||
|
||||
```ts
|
||||
const pdfSkill = skills.define({ id: "pdf-extract", path: "./skills/pdf-extract" });
|
||||
|
||||
chat.skills.set([await pdfSkill.local()]);
|
||||
```
|
||||
|
||||
Built on the [AI SDK cookbook pattern](https://ai-sdk.dev/cookbook/guides/agent-skills) — portable across providers. SDK + CLI only for now; dashboard-editable `SKILL.md` text is on the roadmap.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
`chat.agent` actions are no longer treated as turns. They fire `hydrateMessages` and `onAction` only — no `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no `run()`, no turn-counter increment. The trace span is named `chat action` instead of `chat turn N`.
|
||||
|
||||
`onAction` can now return a `StreamTextResult`, `string`, or `UIMessage` to produce a model response from the action; returning `void` (the previous and now default) is side-effect-only.
|
||||
|
||||
**Migration**: if you previously had `run()` branching on `payload.trigger === "action"`, return your `streamText(...)` from `onAction` instead. If you persisted in `onTurnComplete`, do that work inside `onAction`. For any other state-only action, just remove your skip-the-model workaround — the default is now correct.
|
||||
|
||||
```ts
|
||||
// before
|
||||
onAction: async ({ action }) => {
|
||||
if (action.type === "regenerate") {
|
||||
chat.store.set({ skipModelCall: false });
|
||||
chat.history.slice(0, -1);
|
||||
}
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
if (chat.store.get()?.skipModelCall) return;
|
||||
return streamText({ model, messages, abortSignal: signal });
|
||||
},
|
||||
|
||||
// after
|
||||
onAction: async ({ action, messages, signal }) => {
|
||||
if (action.type === "regenerate") {
|
||||
chat.history.slice(0, -1);
|
||||
return streamText({ model, messages, abortSignal: signal });
|
||||
}
|
||||
},
|
||||
run: async ({ messages, signal }) =>
|
||||
streamText({ model, messages, abortSignal: signal }),
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
`chat.agent` wire is now delta-only — clients ship at most one new message per `.in/append` instead of the full `UIMessage[]` history. The agent rebuilds prior history at run boot from a JSON snapshot in object storage plus a `wait=0` replay of the `session.out` tail. Long chats stop hitting the 512 KiB body cap on `/realtime/v1/sessions/{id}/in/append`. Snapshot writes happen after every `onTurnComplete`, awaited so they survive idle suspend; reads happen only at run boot. Registering a `hydrateMessages` hook short-circuits both the snapshot read/write and the replay — the customer is the source of truth for history.
|
||||
|
||||
Custom transports that constructed `ChatTaskWirePayload` directly need to drop the `messages: UIMessage[]` field and use `message?: UIMessage` (singular). Built-in transports (`TriggerChatTransport`, `AgentChat`) handle the change below the customer-facing surface — most apps need no changes. Configure object-store env vars (`OBJECT_STORE_*`) on your webapp deployment if you haven't already; without an object store and without `hydrateMessages`, conversations don't survive run boundaries.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
Adds `onBoot` to `chat.agent` — a lifecycle hook that fires once per worker process picking up the chat. Runs for the initial run, preloaded runs, AND reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry), before any other hook. Use it to initialize `chat.local`, open per-process resources, or re-hydrate state from your DB on continuation — anywhere the SAME run picking up after suspend/resume isn't enough.
|
||||
|
||||
```ts
|
||||
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
onBoot: async ({ clientData, continuation }) => {
|
||||
const user = await db.user.findUnique({ where: { id: clientData.userId } });
|
||||
userContext.init({ name: user.name, plan: user.plan });
|
||||
},
|
||||
run: async ({ messages, signal }) =>
|
||||
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
|
||||
});
|
||||
```
|
||||
|
||||
If you previously initialized `chat.local` in `onChatStart`, move it to `onBoot` — `onChatStart` is once-per-chat and won't fire on a continuation, leaving `chat.local` uninitialized when `run()` tries to use it. See the upgrade guide for the migration pattern.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Run AI chats as durable Trigger.dev tasks. Define the agent in one function, wire `useChat` to it from React, and the conversation survives page refreshes, network blips, and process restarts — with built-in support for tools, HITL approvals, multi-turn state, and stop-mid-stream cancellation.
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) =>
|
||||
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
|
||||
const transport = useTriggerChatTransport({ task: "my-chat", accessToken });
|
||||
const { messages, sendMessage } = useChat({ transport });
|
||||
```
|
||||
|
||||
Lifecycle hooks (`onPreload`, `onTurnStart`, `onTurnComplete`, etc.) cover the common needs around persistence, validation, and post-turn work. `chat.store` gives you a typed shared-data slot the agent and client both read and write. `chat.endRun()` exits cleanly when the agent decides it's done. The transport's `watch` mode lets a dashboard tab observe a run without driving it.
|
||||
|
||||
Drops the pre-Sessions chat stream constants (`CHAT_STREAM_KEY`, `CHAT_MESSAGES_STREAM_ID`, `CHAT_STOP_STREAM_ID`) — migrate to `sessions.open(id).out` / `.in`.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
Add `chat.headStart` — an opt-in fast-path that runs the first turn's `streamText` step in your warm Next.js / Hono / Workers / Express handler while the trigger agent run boots in parallel. Cold-start TTFC drops by ~50% on the first message; the agent owns step 2+ (tool execution, persistence, hooks) so heavy deps stay where they belong.
|
||||
|
||||
```ts
|
||||
// app/api/chat/route.ts (Next.js / any Web Fetch framework)
|
||||
import { chat } from "@trigger.dev/sdk/chat-server";
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { headStartTools } from "@/lib/chat-tools-schemas"; // schema-only
|
||||
|
||||
export const POST = chat.headStart({
|
||||
agentId: "ai-chat",
|
||||
run: async ({ chat: chatHelper }) =>
|
||||
streamText({
|
||||
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: "You are a helpful AI assistant.",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// browser — opt in by pointing the transport at your handler
|
||||
const transport = useTriggerChatTransport({
|
||||
task: "ai-chat",
|
||||
accessToken,
|
||||
headStart: "/api/chat", // first-turn-only; turn 2+ bypasses the endpoint
|
||||
});
|
||||
```
|
||||
|
||||
For Node-only frameworks (Express, Fastify, Koa, raw `node:http`) use `chat.toNodeListener(handler)` to bridge the Web Fetch handler to `(req, res)`. Adds a new `@trigger.dev/sdk/chat-server` subpath; bundle stays Web Fetch–only with no `node:*` imports.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
Add read primitives to `chat.history` for HITL flows: `getPendingToolCalls()`, `getResolvedToolCalls()`, `extractNewToolResults(message)`, `getChain()`, and `findMessage(messageId)`. These lift the accumulator-walking logic that customers building human-in-the-loop tools were re-implementing into the SDK.
|
||||
|
||||
Use `getPendingToolCalls()` to gate fresh user turns while a tool call is awaiting an answer. Use `extractNewToolResults(message)` to dedup tool results when persisting to your own store — the helper returns only the parts whose `toolCallId` is not already resolved on the chain.
|
||||
|
||||
```ts
|
||||
const pending = chat.history.getPendingToolCalls();
|
||||
if (pending.length > 0) {
|
||||
// an addToolOutput is expected before a new user message
|
||||
}
|
||||
|
||||
onTurnComplete: async ({ responseMessage }) => {
|
||||
const newResults = chat.history.extractNewToolResults(responseMessage);
|
||||
for (const r of newResults) {
|
||||
await db.toolResults.upsert({ id: r.toolCallId, output: r.output, errorText: r.errorText });
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Stamp `gen_ai.conversation.id` (the chat id) on every span and metric emitted from inside a `chat.task` or `chat.agent` run. Lets you filter dashboard spans, runs, and metrics by the chat conversation that produced them — independent of the run boundary, so multi-run chats correlate cleanly. No code changes required on the user side.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Unit-test `chat.agent` definitions offline with `mockChatAgent` from `@trigger.dev/sdk/ai/test`. Drives a real agent's turn loop in-process — no network, no task runtime — so you can send messages, actions, and stop signals via driver methods, inspect captured output chunks, and verify hooks fire. Pairs with `MockLanguageModelV3` from `ai/test` for model mocking. `setupLocals` lets you pre-seed `locals` (DB clients, service stubs) before `run()` starts.
|
||||
|
||||
The broader `runInMockTaskContext` harness it's built on lives at `@trigger.dev/core/v3/test` — useful for unit-testing any task code, not just chat.
|
||||
@@ -1,25 +1,15 @@
|
||||
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Lazy load streamdown components to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { lazy } from "react";
|
||||
import type { CodeHighlighterPlugin } from "streamdown";
|
||||
|
||||
export const StreamdownRenderer = lazy(() =>
|
||||
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
|
||||
([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => {
|
||||
// Type assertion needed: @streamdown/code and streamdown resolve different shiki
|
||||
// versions under pnpm, causing structurally-identical CodeHighlighterPlugin types
|
||||
// to be considered incompatible (different BundledLanguage string unions).
|
||||
const codePlugin = createCodePlugin({
|
||||
themes: [triggerDarkTheme, triggerDarkTheme],
|
||||
}) as unknown as CodeHighlighterPlugin;
|
||||
|
||||
return {
|
||||
default: ({
|
||||
children,
|
||||
isAnimating = false,
|
||||
}: {
|
||||
children: string;
|
||||
isAnimating?: boolean;
|
||||
}) => (
|
||||
<Streamdown isAnimating={isAnimating} plugins={{ code: codePlugin }}>
|
||||
{children}
|
||||
</Streamdown>
|
||||
),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { ThemeRegistrationAny } from "streamdown";
|
||||
|
||||
// Custom Shiki theme matching the Trigger.dev VS Code dark theme.
|
||||
// Colors taken directly from the VS Code extension's tokenColors.
|
||||
export const triggerDarkTheme: ThemeRegistrationAny = {
|
||||
name: "trigger-dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
"editor.background": "#212327",
|
||||
"editor.foreground": "#878C99",
|
||||
"editorLineNumber.foreground": "#484c54",
|
||||
},
|
||||
tokenColors: [
|
||||
// Control flow keywords: pink-purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: { foreground: "#E888F8" },
|
||||
},
|
||||
// Storage type (const, let, var, function, class): purple
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: { foreground: "#8271ED" },
|
||||
},
|
||||
// Storage modifiers (async, export, etc.): purple
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: { foreground: "#8271ED" },
|
||||
},
|
||||
// Keyword operator expressions (new, typeof, instanceof, etc.): purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: { foreground: "#8271ED" },
|
||||
},
|
||||
// Types and namespaces: hot pink
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"entity.other.inherited-class",
|
||||
],
|
||||
settings: { foreground: "#F770C6" },
|
||||
},
|
||||
// Functions: lime/yellow-green
|
||||
{
|
||||
scope: ["entity.name.function", "support.function"],
|
||||
settings: { foreground: "#D9F07C" },
|
||||
},
|
||||
// Variables and parameters: light lavender
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: { foreground: "#CCCBFF" },
|
||||
},
|
||||
// Constants and enums: medium purple
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: { foreground: "#9C9AF2" },
|
||||
},
|
||||
// this/self: purple-blue
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// Object literal keys: medium purple-blue
|
||||
{
|
||||
scope: "meta.object-literal.key",
|
||||
settings: { foreground: "#8B89FF" },
|
||||
},
|
||||
// Strings: sage green
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: { foreground: "#AFEC73" },
|
||||
},
|
||||
// String interpolation punctuation: blue-purple
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: { foreground: "#7A78EA" },
|
||||
},
|
||||
// Template expression reset
|
||||
{
|
||||
scope: "meta.template.expression",
|
||||
settings: { foreground: "#d4d4d4" },
|
||||
},
|
||||
// Operators: gray (same as foreground)
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: { foreground: "#878C99" },
|
||||
},
|
||||
// Comments: olive gray
|
||||
{
|
||||
scope: "comment",
|
||||
settings: { foreground: "#6f736d" },
|
||||
},
|
||||
// Language constants (true, false, null, undefined): purple-blue
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// Numeric constants: light green
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: { foreground: "#b5cea8" },
|
||||
},
|
||||
// Regex: dark red
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: { foreground: "#646695" },
|
||||
},
|
||||
// HTML/JSX tags: purple-blue
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// Tag brackets: dark gray
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: { foreground: "#5F6570" },
|
||||
},
|
||||
// HTML/JSX attributes: light purple
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: { foreground: "#C39EFF" },
|
||||
},
|
||||
// Escape characters: gold
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: { foreground: "#d7ba7d" },
|
||||
},
|
||||
// Regex string: dark red
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: { foreground: "#d16969" },
|
||||
},
|
||||
// Storage: purple-blue
|
||||
{
|
||||
scope: "storage",
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// TS-specific: type casts, math/dom/json constants
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
],
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// Markdown headings: purple-blue bold
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: { foreground: "#9B99FF", fontStyle: "bold" },
|
||||
},
|
||||
// Markup bold: purple-blue
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: { foreground: "#9B99FF", fontStyle: "bold" },
|
||||
},
|
||||
// Markup inline raw: sage green
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: { foreground: "#AFEC73" },
|
||||
},
|
||||
// Markup inserted: light green
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: { foreground: "#b5cea8" },
|
||||
},
|
||||
// Markup deleted: sage green
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: { foreground: "#AFEC73" },
|
||||
},
|
||||
// Markup changed: purple-blue
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: { foreground: "#9B99FF" },
|
||||
},
|
||||
// Invalid: red
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: { foreground: "#f44747" },
|
||||
},
|
||||
// JSX text content
|
||||
{
|
||||
scope: ["meta.jsx.children"],
|
||||
settings: { foreground: "#D7D9DD" },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { tryPrettyJson } from "./ai/aiHelpers";
|
||||
@@ -12,16 +13,6 @@ import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline";
|
||||
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type PromptTab = "overview" | "input" | "template";
|
||||
|
||||
export function PromptSpanDetails({
|
||||
|
||||
@@ -5,24 +5,14 @@ import {
|
||||
ClipboardDocumentIcon,
|
||||
CodeBracketSquareIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
import type { DisplayItem, ToolUse } from "./types";
|
||||
|
||||
// Lazy load streamdown to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
export type PromptLink = {
|
||||
slug: string;
|
||||
version?: string;
|
||||
@@ -221,7 +211,7 @@ export function AssistantResponse({
|
||||
/>
|
||||
{mode === "rendered" ? (
|
||||
<ChatBubble>
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed [overflow-wrap:anywhere]">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
@@ -257,30 +247,59 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
type ToolTab = "input" | "output" | "details";
|
||||
type ToolTab = "input" | "output" | "details" | "agent";
|
||||
|
||||
function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
export function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
const hasInput = tool.inputJson !== "{}";
|
||||
const hasResult = !!tool.resultOutput;
|
||||
const hasDetails = !!tool.description || !!tool.parametersJson;
|
||||
const hasSubAgent = !!tool.subAgent;
|
||||
|
||||
const availableTabs: ToolTab[] = [
|
||||
...(hasSubAgent ? (["agent"] as const) : []),
|
||||
...(hasInput ? (["input"] as const) : []),
|
||||
...(hasResult ? (["output"] as const) : []),
|
||||
...(hasDetails ? (["details"] as const) : []),
|
||||
];
|
||||
|
||||
const defaultTab: ToolTab | null = hasInput ? "input" : null;
|
||||
const [activeTab, setActiveTab] = useState<ToolTab | null>(defaultTab);
|
||||
const [activeTab, setActiveTab] = useState<ToolTab | null>(
|
||||
hasSubAgent ? "agent" : hasInput ? "input" : null
|
||||
);
|
||||
|
||||
// Auto-select input tab when input arrives after initial render (e.g. streaming tool calls)
|
||||
useEffect(() => {
|
||||
if (!hasSubAgent && hasInput && activeTab === null) {
|
||||
setActiveTab("input");
|
||||
}
|
||||
}, [hasInput, hasSubAgent]);
|
||||
|
||||
function handleTabClick(tab: ToolTab) {
|
||||
setActiveTab(activeTab === tab ? null : tab);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-grid-bright bg-charcoal-800/40">
|
||||
<div
|
||||
className={`rounded-sm border bg-charcoal-800/40 ${
|
||||
hasSubAgent ? "border-indigo-500/30" : "border-grid-bright"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<code className="font-mono text-xs text-text-bright">{tool.toolName}</code>
|
||||
{hasSubAgent && (
|
||||
<svg className="size-3.5 text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
<code
|
||||
className={`font-mono text-xs ${hasSubAgent ? "text-indigo-300" : "text-text-bright"}`}
|
||||
>
|
||||
{tool.toolName}
|
||||
</code>
|
||||
{hasSubAgent && tool.subAgent?.isStreaming && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-indigo-400">
|
||||
<span className="inline-block size-1.5 animate-pulse rounded-full bg-indigo-400" />
|
||||
streaming
|
||||
</span>
|
||||
)}
|
||||
{tool.resultSummary && (
|
||||
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
|
||||
)}
|
||||
@@ -288,7 +307,11 @@ function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
|
||||
{availableTabs.length > 0 && (
|
||||
<>
|
||||
<div className="flex gap-0 border-t border-grid-bright">
|
||||
<div
|
||||
className={`flex gap-0 border-t ${
|
||||
hasSubAgent ? "border-indigo-500/20" : "border-grid-bright"
|
||||
}`}
|
||||
>
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -304,6 +327,10 @@ function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "agent" && hasSubAgent && (
|
||||
<SubAgentContent parts={tool.subAgent!.parts} />
|
||||
)}
|
||||
|
||||
{activeTab === "input" && hasInput && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
<CodeBlock
|
||||
@@ -317,12 +344,24 @@ function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
|
||||
{activeTab === "output" && hasResult && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
<CodeBlock
|
||||
code={tool.resultOutput!}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
{isJsonString(tool.resultOutput!) ? (
|
||||
<CodeBlock
|
||||
code={tool.resultOutput!}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
) : (
|
||||
<div className="p-2.5 font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense
|
||||
fallback={
|
||||
<span className="whitespace-pre-wrap">{tool.resultOutput}</span>
|
||||
}
|
||||
>
|
||||
<StreamdownRenderer>{tool.resultOutput!}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -351,3 +390,85 @@ function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubAgentContent({ parts }: { parts: any[] }) {
|
||||
// Extract sub-agent run ID from injected metadata part
|
||||
const runPart = parts.find(
|
||||
(p: any) => p.type === "data-subagent-run" && p.data?.runId
|
||||
);
|
||||
const subAgentRunId = runPart?.data?.runId as string | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-indigo-500/20 p-2.5">
|
||||
{subAgentRunId && (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton
|
||||
to={`/runs/${subAgentRunId}`}
|
||||
variant="tertiary/small"
|
||||
target="_blank"
|
||||
>
|
||||
View sub-agent run
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
{parts.map((part: any, j: number) => {
|
||||
const partType = part.type as string;
|
||||
|
||||
// Skip the injected metadata part — already rendered above
|
||||
if (partType === "data-subagent-run") return null;
|
||||
|
||||
if (partType === "text" && part.text) {
|
||||
return <AssistantResponse key={j} text={part.text} headerLabel="" />;
|
||||
}
|
||||
|
||||
if (partType === "step-start") {
|
||||
return (
|
||||
<div key={j} className="flex items-center gap-2 py-0.5">
|
||||
<div className="flex-1 border-t border-dashed border-charcoal-650" />
|
||||
<span className="text-[10px] text-charcoal-500">step</span>
|
||||
<div className="flex-1 border-t border-dashed border-charcoal-650" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (partType.startsWith("tool-")) {
|
||||
const subToolName = partType.slice(5);
|
||||
return (
|
||||
<ToolUseRow
|
||||
key={j}
|
||||
tool={{
|
||||
toolCallId: part.toolCallId ?? `sub-tool-${j}`,
|
||||
toolName: subToolName,
|
||||
inputJson: JSON.stringify(part.input ?? {}, null, 2),
|
||||
resultOutput:
|
||||
part.output != null
|
||||
? typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output, null, 2)
|
||||
: undefined,
|
||||
resultSummary:
|
||||
part.state === "input-streaming" || part.state === "input-available"
|
||||
? "calling..."
|
||||
: part.state === "output-error"
|
||||
? `error: ${part.errorText ?? "unknown"}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (partType === "reasoning" && part.text) {
|
||||
return (
|
||||
<div key={j} className="border-l-2 border-amber-500/40 pl-2">
|
||||
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">
|
||||
{part.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
@@ -20,16 +21,6 @@ import type { AISpanData, DisplayItem } from "./types";
|
||||
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline";
|
||||
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type AITab = "overview" | "messages" | "tools" | "prompt";
|
||||
|
||||
export function AISpanDetails({
|
||||
|
||||
@@ -22,6 +22,11 @@ export type ToolUse = {
|
||||
resultSummary?: string;
|
||||
/** Full formatted result for display in a code block */
|
||||
resultOutput?: string;
|
||||
/** Sub-agent output — when the tool result is a UIMessage with parts */
|
||||
subAgent?: {
|
||||
parts: any[];
|
||||
isStreaming: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+17
-17
@@ -1,8 +1,9 @@
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
@@ -11,16 +12,6 @@ import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "result"; success: true; payload: string }
|
||||
@@ -31,11 +22,19 @@ export function AIPayloadTabContent({
|
||||
payloadSchema,
|
||||
taskIdentifier,
|
||||
getCurrentPayload,
|
||||
generateButtonLabel = "Generate payload",
|
||||
placeholder,
|
||||
examplePromptsOverride,
|
||||
isAgent = false,
|
||||
}: {
|
||||
onPayloadGenerated: (payload: string) => void;
|
||||
payloadSchema?: unknown;
|
||||
taskIdentifier: string;
|
||||
getCurrentPayload?: () => string;
|
||||
generateButtonLabel?: string;
|
||||
placeholder?: string;
|
||||
examplePromptsOverride?: string[];
|
||||
isAgent?: boolean;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -73,6 +72,7 @@ export function AIPayloadTabContent({
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("taskIdentifier", taskIdentifier);
|
||||
formData.append("isAgent", isAgent ? "true" : "false");
|
||||
if (payloadSchema) {
|
||||
formData.append("payloadSchema", JSON.stringify(payloadSchema));
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function AIPayloadTabContent({
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload]
|
||||
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
@@ -191,7 +191,7 @@ export function AIPayloadTabContent({
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const examplePrompts = payloadSchema
|
||||
const examplePrompts = examplePromptsOverride ?? (payloadSchema
|
||||
? [
|
||||
"Generate a valid payload",
|
||||
"Generate a payload with edge cases",
|
||||
@@ -201,7 +201,7 @@ export function AIPayloadTabContent({
|
||||
"Generate a simple JSON payload",
|
||||
"Generate a payload with nested objects",
|
||||
"Generate a payload with an array of items",
|
||||
];
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -215,9 +215,9 @@ export function AIPayloadTabContent({
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
payloadSchema
|
||||
placeholder ?? (payloadSchema
|
||||
? "e.g. generate a payload for a new user signup"
|
||||
: "e.g. generate a JSON payload with name, email, and age fields"
|
||||
: "e.g. generate a JSON payload with name, email, and age fields")
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
@@ -251,7 +251,7 @@ export function AIPayloadTabContent({
|
||||
className={cn(!prompt.trim() && "opacity-50")}
|
||||
onClick={() => handleSubmit()}
|
||||
>
|
||||
Generate payload
|
||||
{generateButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.23",
|
||||
"@ai-sdk/react": "^3.0.0",
|
||||
"@ariakit/react": "^0.4.6",
|
||||
"@ariakit/react-core": "^0.4.6",
|
||||
"@aws-sdk/client-ecr": "^3.931.0",
|
||||
@@ -219,7 +220,8 @@
|
||||
"sonner": "^1.0.3",
|
||||
"sql-formatter": "^15.4.10",
|
||||
"sqs-consumer": "^7.4.0",
|
||||
"streamdown": "^1.4.0",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"streamdown": "^2.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob
|
||||
// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors
|
||||
// the testcontainer pattern from `objectStore.test.ts`.
|
||||
//
|
||||
// What this verifies end-to-end:
|
||||
// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl`
|
||||
// to mint a presigned PUT, then PUTs JSON to it.
|
||||
// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a
|
||||
// presigned GET, then fetches and parses.
|
||||
// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts.
|
||||
// - The blob round-trips with `version: 1` shape preserved.
|
||||
// - 404 (no snapshot for a fresh session) returns `undefined`, not an
|
||||
// error.
|
||||
//
|
||||
// This is the integration safety net behind the unit tests in
|
||||
// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock
|
||||
// `fetch`; this one drives a real S3-compatible backend.
|
||||
|
||||
import { postgresAndMinioTest } from "@internal/testcontainers";
|
||||
import { apiClientManager } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
__readChatSnapshotProductionPathForTests as readChatSnapshot,
|
||||
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
|
||||
type ChatSnapshotV1,
|
||||
} from "@trigger.dev/sdk/ai";
|
||||
import type { UIMessage } from "ai";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { env } from "~/env.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}): ChatSnapshotV1 {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: 1_700_000_000_000,
|
||||
messages: opts.messages ?? [
|
||||
{
|
||||
id: "u-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
id: "a-1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "world" }],
|
||||
},
|
||||
],
|
||||
lastOutEventId: opts.lastOutEventId ?? "evt-42",
|
||||
lastOutTimestamp: 1_700_000_000_500,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
|
||||
* api client whose `getPayloadUrl` / `createUploadPayloadUrl` return
|
||||
* presigned URLs minted by the webapp's real `generatePresignedUrl`
|
||||
* (which signs against MinIO).
|
||||
*
|
||||
* The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write
|
||||
* the blob, so MinIO ends up holding the actual bytes.
|
||||
*/
|
||||
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
|
||||
async getPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
async createUploadPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Suppress noisy warnings from logger.warn during error-path tests.
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
warnSpy?.mockRestore();
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("chat snapshot integration (MinIO + SDK helpers)", () => {
|
||||
postgresAndMinioTest("round-trips a snapshot through real MinIO", async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_rt", envSlug: "dev" });
|
||||
|
||||
const sessionId = "sess_round_trip_1";
|
||||
const snapshot = makeSnapshot();
|
||||
|
||||
// Write through the SDK helper — should land in MinIO at
|
||||
// `packets/proj_snap_rt/dev/sessions/sess_round_trip_1/snapshot.json`.
|
||||
await writeChatSnapshot(sessionId, snapshot);
|
||||
|
||||
// Read back through the SDK helper — should reconstruct the original.
|
||||
const result = await readChatSnapshot(sessionId);
|
||||
|
||||
expect(result).toEqual(snapshot);
|
||||
});
|
||||
|
||||
postgresAndMinioTest("returns undefined for a fresh session with no snapshot", async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_404", envSlug: "dev" });
|
||||
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// Session never had a snapshot written — read returns undefined.
|
||||
const result = await readChatSnapshot("sess_never_existed");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
postgresAndMinioTest("overwrites a prior snapshot in place (single-writer)", async ({ minioConfig }) => {
|
||||
// The runtime guarantees one attempt alive at a time, and
|
||||
// `writeChatSnapshot` runs awaited after `onTurnComplete`. Verify
|
||||
// that a second write to the same key replaces the first cleanly —
|
||||
// the read-after-write reflects the latest blob.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_overwrite", envSlug: "dev" });
|
||||
|
||||
const sessionId = "sess_overwrite";
|
||||
|
||||
const turn1 = makeSnapshot({
|
||||
messages: [
|
||||
{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] },
|
||||
],
|
||||
lastOutEventId: "evt-turn1",
|
||||
});
|
||||
const turn2 = makeSnapshot({
|
||||
messages: [
|
||||
{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] },
|
||||
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "reply-1" }] },
|
||||
{ id: "u-2", role: "user", parts: [{ type: "text", text: "second" }] },
|
||||
{ id: "a-2", role: "assistant", parts: [{ type: "text", text: "reply-2" }] },
|
||||
],
|
||||
lastOutEventId: "evt-turn2",
|
||||
});
|
||||
|
||||
await writeChatSnapshot(sessionId, turn1);
|
||||
await writeChatSnapshot(sessionId, turn2);
|
||||
|
||||
const result = await readChatSnapshot(sessionId);
|
||||
expect(result).toEqual(turn2);
|
||||
expect(result?.messages).toHaveLength(4);
|
||||
expect(result?.lastOutEventId).toBe("evt-turn2");
|
||||
});
|
||||
|
||||
postgresAndMinioTest("isolates snapshots by sessionId (no cross-talk)", async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_iso", envSlug: "dev" });
|
||||
|
||||
const sessA = "sess_iso_A";
|
||||
const sessB = "sess_iso_B";
|
||||
const snapA = makeSnapshot({ lastOutEventId: "evt-A" });
|
||||
const snapB = makeSnapshot({ lastOutEventId: "evt-B" });
|
||||
|
||||
await writeChatSnapshot(sessA, snapA);
|
||||
await writeChatSnapshot(sessB, snapB);
|
||||
|
||||
const readA = await readChatSnapshot(sessA);
|
||||
const readB = await readChatSnapshot(sessB);
|
||||
|
||||
expect(readA?.lastOutEventId).toBe("evt-A");
|
||||
expect(readB?.lastOutEventId).toBe("evt-B");
|
||||
// Distinct objects — modifying one shouldn't affect the other.
|
||||
expect(readA?.lastOutEventId).not.toBe(readB?.lastOutEventId);
|
||||
});
|
||||
|
||||
postgresAndMinioTest("handles snapshots with large message lists (~50 messages)", async ({ minioConfig }) => {
|
||||
// Stress test: a 50-turn chat snapshot. Plan F.4 mentions the
|
||||
// pre-change baseline grew past 512 KiB around turn 10-30 with tool
|
||||
// use; the post-slim wire keeps wire payloads small but the snapshot
|
||||
// itself can still get large. Verify the helpers handle a realistic
|
||||
// payload size.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_big", envSlug: "dev" });
|
||||
|
||||
const messages: UIMessage[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
messages.push({
|
||||
id: `u-${i}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: `user message ${i}: ${"x".repeat(200)}` }],
|
||||
});
|
||||
messages.push({
|
||||
id: `a-${i}`,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: `assistant reply ${i}: ${"y".repeat(500)}` }],
|
||||
});
|
||||
}
|
||||
const snapshot = makeSnapshot({ messages, lastOutEventId: "evt-50" });
|
||||
|
||||
await writeChatSnapshot("sess_big_chat", snapshot);
|
||||
const result = await readChatSnapshot("sess_big_chat");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.messages).toHaveLength(100);
|
||||
expect(result!.lastOutEventId).toBe("evt-50");
|
||||
// Spot-check ordering integrity — the messages array round-tripped
|
||||
// in the same order.
|
||||
expect(result!.messages[0]!.id).toBe("u-0");
|
||||
expect(result!.messages[99]!.id).toBe("a-49");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
// Plan F.3: integration test for the crash-recovery boot path. The
|
||||
// scenario it locks down:
|
||||
//
|
||||
// 1. Run A streams chunks to `session.out` and `onTurnComplete` fires.
|
||||
// 2. Run A crashes BEFORE `writeChatSnapshot` lands the post-turn
|
||||
// blob (or the write fails silently — both have the same effect).
|
||||
// 3. Run B boots: `readChatSnapshot` returns `undefined` (no snapshot
|
||||
// yet, or stale-from-prior-turn). Replay then drains
|
||||
// `session.out` from the snapshot's `lastOutEventId` (or seq 0)
|
||||
// and reduces the chunks back into UIMessage[].
|
||||
// 4. The accumulator is consistent — Run A's completed chunks reach
|
||||
// Run B's run loop without losing data.
|
||||
//
|
||||
// Plan section H.1 / H.4 spell out the "snapshot didn't make it before
|
||||
// crash" path; this test is the integration safety net behind the
|
||||
// unit tests in `packages/trigger-sdk/test/replay-session-out.test.ts`.
|
||||
//
|
||||
// We exercise the SDK's `__replaySessionOutTailProductionPathForTests`
|
||||
// against a stubbed `apiClient.readSessionStreamRecords` — the new
|
||||
// non-SSE records endpoint introduced in plan task #22. The replay path
|
||||
// is a single GET that returns whatever's already on the stream; no
|
||||
// long-poll. MinIO is provisioned to keep parity with
|
||||
// `chat-snapshot-integration.test.ts` (the snapshot read path runs
|
||||
// through it), even though the replay path itself doesn't read from S3.
|
||||
|
||||
import { postgresAndMinioTest } from "@internal/testcontainers";
|
||||
import { apiClientManager } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
__readChatSnapshotProductionPathForTests as readChatSnapshot,
|
||||
__replaySessionOutTailProductionPathForTests as replaySessionOutTail,
|
||||
type ChatSnapshotV1,
|
||||
} from "@trigger.dev/sdk/ai";
|
||||
import type { UIMessageChunk } from "ai";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { env } from "~/env.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function textTurn(id: string, text: string): UIMessageChunk[] {
|
||||
return [
|
||||
{ type: "start", messageId: id, messageMetadata: { role: "assistant" } } 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()` so:
|
||||
* - `getPayloadUrl` / `createUploadPayloadUrl` mint MinIO presigned URLs
|
||||
* via the webapp's real `generatePresignedUrl` (so snapshot reads
|
||||
* hit a real S3-compatible backend).
|
||||
* - `readSessionStreamRecords` returns the canonical
|
||||
* `{ records: [{ data, id, seqNum }] }` shape — `data` is the
|
||||
* JSON-encoded chunk body, mirroring the webapp's S2 record shape.
|
||||
*/
|
||||
function stubApiClient(opts: {
|
||||
projectRef: string;
|
||||
envSlug: string;
|
||||
sessionOutChunks: unknown[];
|
||||
}) {
|
||||
const records = opts.sessionOutChunks.map((chunk, i) => ({
|
||||
data: typeof chunk === "string" ? chunk : JSON.stringify(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({
|
||||
async getPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
async createUploadPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
readSessionStreamRecords: readRecordsSpy,
|
||||
} as never);
|
||||
return readRecordsSpy;
|
||||
}
|
||||
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
warnSpy?.mockRestore();
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("replay after crash (MinIO + SDK helpers)", () => {
|
||||
postgresAndMinioTest(
|
||||
"boot reconstructs accumulator from session.out replay when no snapshot exists",
|
||||
async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// The crashed run's session.out: two completed assistant turns, no
|
||||
// snapshot ever written. Boot must recover both via replay.
|
||||
const chunks = [...textTurn("a-1", "first turn"), ...textTurn("a-2", "second turn")];
|
||||
stubApiClient({
|
||||
projectRef: "proj_replay_crash",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: chunks,
|
||||
});
|
||||
|
||||
// Step 1: read snapshot — returns undefined (fresh boot, no snap).
|
||||
const snapshot = await readChatSnapshot("sess_no_snap");
|
||||
expect(snapshot).toBeUndefined();
|
||||
|
||||
// Step 2: replay tail.
|
||||
const replayed = await replaySessionOutTail("sess_no_snap");
|
||||
|
||||
expect(replayed).toHaveLength(2);
|
||||
expect(replayed.map((m) => m.id)).toEqual(["a-1", "a-2"]);
|
||||
const texts = replayed.flatMap((m) =>
|
||||
(m.parts as Array<{ type: string; text?: string }>)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
);
|
||||
expect(texts).toEqual(["first turn", "second turn"]);
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"boot replays only chunks AFTER snapshot.lastOutEventId (resume cursor)",
|
||||
async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
// The replay helper accepts the snapshot's `lastEventId` cursor
|
||||
// and forwards it as `afterEventId` on the records endpoint —
|
||||
// that's the cursor field name on the new non-SSE route. Here we
|
||||
// feed only the post-snapshot chunks (modeling what the server
|
||||
// returns for `afterEventId=evt-snapped`) and verify the helper
|
||||
// threads the cursor through.
|
||||
const readRecordsSpy = stubApiClient({
|
||||
projectRef: "proj_replay_resume",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: textTurn("a-after-snap", "post-snapshot turn"),
|
||||
});
|
||||
|
||||
const result = await replaySessionOutTail("sess_resume", { lastEventId: "evt-snapped" });
|
||||
|
||||
expect(readRecordsSpy).toHaveBeenCalledWith(
|
||||
"sess_resume",
|
||||
"out",
|
||||
expect.objectContaining({ afterEventId: "evt-snapped" })
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("a-after-snap");
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"boot returns [] when session.out is empty (first-ever turn, no snapshot)",
|
||||
async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
stubApiClient({
|
||||
projectRef: "proj_replay_empty",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: [],
|
||||
});
|
||||
|
||||
const snapshot = await readChatSnapshot("sess_empty");
|
||||
expect(snapshot).toBeUndefined();
|
||||
|
||||
const replayed = await replaySessionOutTail("sess_empty");
|
||||
expect(replayed).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"boot drops orphaned trailing tool parts (cleanupAbortedParts) — partial crash",
|
||||
async ({ minioConfig }) => {
|
||||
// Simulates a true mid-turn crash: assistant finished one turn,
|
||||
// then started a tool-call but the run died before resolution.
|
||||
// Replay must surface the completed turn but NOT include the
|
||||
// orphaned tool part in `input-streaming` state.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({
|
||||
projectRef: "proj_replay_partial",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: [
|
||||
...textTurn("a-complete", "I finished step 1"),
|
||||
// Partial tool turn — no tool-input-end, no finish.
|
||||
{ type: "start", messageId: "a-orphan", messageMetadata: { role: "assistant" } } as UIMessageChunk,
|
||||
{ type: "tool-input-start", id: "tc-cut", toolName: "search" } as UIMessageChunk,
|
||||
{ type: "tool-input-delta", id: "tc-cut", delta: '{"q":"x"}' } as UIMessageChunk,
|
||||
],
|
||||
});
|
||||
|
||||
const replayed = await replaySessionOutTail("sess_partial_crash");
|
||||
|
||||
// Completed turn always present.
|
||||
expect(replayed.find((m) => m.id === "a-complete")).toBeTruthy();
|
||||
// Orphaned tool-call never surfaces in `input-streaming` state.
|
||||
const orphan = replayed.find((m) => m.id === "a-orphan");
|
||||
if (orphan) {
|
||||
const stillStreaming = (orphan.parts as Array<{ toolCallId?: string; state?: string }>).find(
|
||||
(p) => p.toolCallId === "tc-cut" && p.state === "input-streaming"
|
||||
);
|
||||
expect(stillStreaming).toBeUndefined();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"snapshot+replay merge: snapshot supplies user msgs, replay supplies assistants",
|
||||
async ({ minioConfig }) => {
|
||||
// The boot orchestration calls
|
||||
// `mergeByIdReplaceWins(snapshot.messages, replayed)`. The runtime
|
||||
// contract is that user messages live in snapshot only (session.in
|
||||
// never goes through replay) and assistants come from replay
|
||||
// (which carries the freshest representation). Here we simulate
|
||||
// the realistic split: snapshot has [u-1, a-1-stale], replay has
|
||||
// [a-1-fresh, a-2-new]. After merge the accumulator should reflect
|
||||
// the fresh assistant + new assistant, with the user message
|
||||
// preserved.
|
||||
//
|
||||
// Note: this is a pre-merge round-trip — we drive the read and
|
||||
// replay through real MinIO + stubbed S2 to confirm both arrive
|
||||
// intact for the orchestration to merge.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
// Pre-write a snapshot to MinIO via real apiClient stub.
|
||||
const sessionId = "sess_merge_round_trip";
|
||||
const snapshot: ChatSnapshotV1 = {
|
||||
version: 1,
|
||||
savedAt: 1_700_000_000_000,
|
||||
messages: [
|
||||
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "stale-assistant" }] },
|
||||
],
|
||||
lastOutEventId: "evt-prev",
|
||||
lastOutTimestamp: 1_700_000_000_500,
|
||||
};
|
||||
|
||||
// Use the SDK's own writer to lay the snapshot down, then swap
|
||||
// the stub to also serve replay chunks for the read path.
|
||||
stubApiClient({
|
||||
projectRef: "proj_merge",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: [],
|
||||
});
|
||||
const { __writeChatSnapshotProductionPathForTests: writeSnapshot } = await import(
|
||||
"@trigger.dev/sdk/ai"
|
||||
);
|
||||
await writeSnapshot(sessionId, snapshot);
|
||||
|
||||
// Restubbing for the boot phase: replay tail carries the fresh
|
||||
// assistant for `a-1` plus a brand-new `a-2`. The orchestration's
|
||||
// merge would replace `a-1` and append `a-2` after `u-1`.
|
||||
vi.restoreAllMocks();
|
||||
stubApiClient({
|
||||
projectRef: "proj_merge",
|
||||
envSlug: "dev",
|
||||
sessionOutChunks: [
|
||||
...textTurn("a-1", "fresh-assistant"),
|
||||
...textTurn("a-2", "next-assistant"),
|
||||
],
|
||||
});
|
||||
|
||||
const readBack = await readChatSnapshot(sessionId);
|
||||
expect(readBack?.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]);
|
||||
|
||||
const replayed = await replaySessionOutTail(sessionId, {
|
||||
lastEventId: readBack?.lastOutEventId,
|
||||
});
|
||||
expect(replayed.map((m) => m.id)).toEqual(["a-1", "a-2"]);
|
||||
// Replay's `a-1` carries the fresh content — when merge runs in
|
||||
// the runtime, this version would replace the snapshot's stale
|
||||
// `a-1`.
|
||||
const replayedA1Text = (replayed[0]!.parts as Array<{ type: string; text?: string }>)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
expect(replayedA1Text).toBe("fresh-assistant");
|
||||
}
|
||||
);
|
||||
});
|
||||
+2
-1
@@ -82,7 +82,8 @@
|
||||
"@sentry/remix@9.46.0": "patches/@sentry__remix@9.46.0.patch",
|
||||
"@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch",
|
||||
"antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch",
|
||||
"@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch"
|
||||
"@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch",
|
||||
"streamdown@2.5.0": "patches/streamdown@2.5.0.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "5.5.4",
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"./extensions/typescript": "./src/extensions/typescript.ts",
|
||||
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
|
||||
"./extensions/playwright": "./src/extensions/playwright.ts",
|
||||
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
|
||||
"./extensions/lightpanda": "./src/extensions/lightpanda.ts",
|
||||
"./extensions/secureExec": "./src/extensions/secureExec.ts"
|
||||
},
|
||||
"sourceDialects": [
|
||||
"@triggerdotdev/source"
|
||||
@@ -65,6 +66,9 @@
|
||||
],
|
||||
"extensions/lightpanda": [
|
||||
"dist/commonjs/extensions/lightpanda.d.ts"
|
||||
],
|
||||
"extensions/secureExec": [
|
||||
"dist/commonjs/extensions/secureExec.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -207,6 +211,17 @@
|
||||
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
|
||||
"default": "./dist/commonjs/extensions/lightpanda.js"
|
||||
}
|
||||
},
|
||||
"./extensions/secureExec": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/secureExec.ts",
|
||||
"types": "./dist/esm/extensions/secureExec.d.ts",
|
||||
"default": "./dist/esm/extensions/secureExec.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/secureExec.d.ts",
|
||||
"default": "./dist/commonjs/extensions/secureExec.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "./dist/commonjs/index.js",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BuildTarget } from "@trigger.dev/core/v3";
|
||||
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { readPackageJSON } from "pkg-types";
|
||||
|
||||
export type SecureExecOptions = {
|
||||
/**
|
||||
* Packages available inside the sandbox at runtime.
|
||||
*
|
||||
* These are `require()`'d inside the V8 isolate at runtime — the bundler
|
||||
* never sees them statically. They are marked external and installed as
|
||||
* deploy dependencies.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* secureExec({ packages: ["jszip", "lodash"] })
|
||||
* ```
|
||||
*/
|
||||
packages?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build extension for [secure-exec](https://secureexec.dev) — run untrusted
|
||||
* JavaScript/TypeScript in V8 isolates with configurable permissions.
|
||||
*
|
||||
* Handles the esbuild workarounds needed for secure-exec's runtime
|
||||
* `require.resolve` calls, native binaries, and module-scope resolution.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { secureExec } from "@trigger.dev/build/extensions/secureExec";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* build: {
|
||||
* extensions: [secureExec()],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function secureExec(options?: SecureExecOptions): BuildExtension {
|
||||
return new SecureExecExtension(options ?? {});
|
||||
}
|
||||
|
||||
class SecureExecExtension implements BuildExtension {
|
||||
public readonly name = "SecureExecExtension";
|
||||
|
||||
private userPackages: string[];
|
||||
|
||||
constructor(options: SecureExecOptions) {
|
||||
this.userPackages = options.packages ?? [];
|
||||
}
|
||||
|
||||
externalsForTarget(_target: BuildTarget) {
|
||||
return [
|
||||
// esbuild must not be bundled — it locates its native binary via a
|
||||
// relative path from its JS API entry point. secure-exec uses esbuild
|
||||
// at runtime to bundle polyfills for sandbox code.
|
||||
"esbuild",
|
||||
// User-specified packages are require()'d inside the V8 sandbox at
|
||||
// runtime — the bundler never sees them statically.
|
||||
...this.userPackages,
|
||||
];
|
||||
}
|
||||
|
||||
onBuildStart(context: BuildContext) {
|
||||
context.logger.debug(`Adding ${this.name} esbuild plugins`);
|
||||
|
||||
// Plugin 1: Replace node-stdlib-browser with pre-resolved paths.
|
||||
//
|
||||
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
|
||||
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
|
||||
// Fix: load the real node-stdlib-browser at build time (where require.resolve
|
||||
// works), capture the resolved path map, and inline it as a static export.
|
||||
const workingDir = context.workingDir;
|
||||
context.registerPlugin({
|
||||
name: "secure-exec-stdlib-resolver",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
|
||||
path: "node-stdlib-browser",
|
||||
namespace: "secure-exec-nsb-resolved",
|
||||
}));
|
||||
build.onLoad({ filter: /.*/, namespace: "secure-exec-nsb-resolved" }, () => {
|
||||
const buildRequire = createRequire(join(workingDir, "package.json"));
|
||||
const resolved = buildRequire("node-stdlib-browser");
|
||||
return {
|
||||
contents: `export default ${JSON.stringify(resolved)};`,
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Plugin 2: Inline bridge.js at build time.
|
||||
//
|
||||
// bridge-loader.js in @secure-exec/node(js) uses __dirname and
|
||||
// require.resolve("@secure-exec/core") at module scope to locate
|
||||
// dist/bridge.js on disk. This fails in Trigger's bundled output.
|
||||
// Fix: read bridge.js content at build time and inline it as a
|
||||
// string literal so no runtime filesystem resolution is needed.
|
||||
//
|
||||
context.registerPlugin({
|
||||
name: "secure-exec-bridge-inline",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
|
||||
(args) => {
|
||||
try {
|
||||
const buildRequire = createRequire(args.path);
|
||||
const coreEntry = buildRequire.resolve("@secure-exec/core");
|
||||
const coreRoot = resolve(dirname(coreEntry), "..");
|
||||
const bridgeCode = readFileSync(join(coreRoot, "dist", "bridge.js"), "utf8");
|
||||
|
||||
return {
|
||||
contents: [
|
||||
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
|
||||
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
|
||||
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
|
||||
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
|
||||
].join("\n"),
|
||||
loader: "js",
|
||||
};
|
||||
} catch {
|
||||
// If we can't inline the bridge, let the normal loader handle it.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onBuildComplete(context: BuildContext, _manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`Adding ${this.name} deploy dependencies`);
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
|
||||
// Resolve versions for user-specified sandbox packages
|
||||
for (const pkg of this.userPackages) {
|
||||
try {
|
||||
const modulePath = await context.resolvePath(pkg);
|
||||
if (!modulePath) {
|
||||
dependencies[pkg] = "latest";
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJSON = await readPackageJSON(dirname(modulePath));
|
||||
dependencies[pkg] = packageJSON.version ?? "latest";
|
||||
} catch {
|
||||
context.logger.warn(
|
||||
`Could not resolve version for sandbox package ${pkg}, defaulting to latest`
|
||||
);
|
||||
dependencies[pkg] = "latest";
|
||||
}
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "secureExec",
|
||||
dependencies,
|
||||
image: {
|
||||
// isolated-vm requires native compilation tools
|
||||
pkgs: ["python3", "make", "g++"],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./internal/additionalFiles.js";
|
||||
export * from "./internal/copyFiles.js";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3";
|
||||
import { BuildContext } from "@trigger.dev/core/v3/build";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, posix, relative } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
import {
|
||||
copyMatcherResults,
|
||||
findFilesByMatchers,
|
||||
type MatcherResult,
|
||||
} from "./copyFiles.js";
|
||||
|
||||
export type AdditionalFilesOptions = {
|
||||
files: string[];
|
||||
@@ -14,12 +16,13 @@ export async function addAdditionalFilesToBuild(
|
||||
context: BuildContext,
|
||||
manifest: BuildManifest
|
||||
) {
|
||||
// Copy any static assets to the destination
|
||||
const staticAssets = await findStaticAssetFiles(options.files ?? [], manifest.outputPath, {
|
||||
cwd: context.workingDir,
|
||||
});
|
||||
const matcherResults: MatcherResult[] = await findFilesByMatchers(
|
||||
options.files ?? [],
|
||||
manifest.outputPath,
|
||||
{ cwd: context.workingDir }
|
||||
);
|
||||
|
||||
for (const { assets, matcher } of staticAssets) {
|
||||
for (const { assets, matcher } of matcherResults) {
|
||||
if (assets.length === 0) {
|
||||
context.logger.warn(`[${source}] No files found for matcher`, matcher);
|
||||
} else {
|
||||
@@ -27,80 +30,7 @@ export async function addAdditionalFilesToBuild(
|
||||
}
|
||||
}
|
||||
|
||||
await copyStaticAssets(staticAssets, source, context);
|
||||
}
|
||||
|
||||
type MatchedStaticAssets = { source: string; destination: string }[];
|
||||
|
||||
type FoundStaticAssetFiles = Array<{
|
||||
matcher: string;
|
||||
assets: MatchedStaticAssets;
|
||||
}>;
|
||||
|
||||
async function findStaticAssetFiles(
|
||||
matchers: string[],
|
||||
destinationPath: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<FoundStaticAssetFiles> {
|
||||
const result: FoundStaticAssetFiles = [];
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const assets = await findStaticAssetsForMatcher(matcher, destinationPath, options);
|
||||
|
||||
result.push({ matcher, assets });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function findStaticAssetsForMatcher(
|
||||
matcher: string,
|
||||
destinationPath: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<MatchedStaticAssets> {
|
||||
const result: MatchedStaticAssets = [];
|
||||
|
||||
const files = await glob({
|
||||
patterns: [matcher],
|
||||
cwd: options?.cwd,
|
||||
ignore: options?.ignore ?? [],
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
await copyMatcherResults(matcherResults, (pair) => {
|
||||
context.logger.debug(`[${source}] Copying ${pair.source} to ${pair.destination}`);
|
||||
});
|
||||
|
||||
let matches = 0;
|
||||
|
||||
for (const file of files) {
|
||||
matches++;
|
||||
|
||||
const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file)
|
||||
.split(posix.sep)
|
||||
.filter((p) => p !== "..")
|
||||
.join(posix.sep);
|
||||
|
||||
const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir);
|
||||
|
||||
result.push({
|
||||
source: file,
|
||||
destination: relativeDestinationPath,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function copyStaticAssets(
|
||||
staticAssetFiles: FoundStaticAssetFiles,
|
||||
sourceName: string,
|
||||
context: BuildContext
|
||||
): Promise<void> {
|
||||
for (const { assets } of staticAssetFiles) {
|
||||
for (const { source, destination } of assets) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
|
||||
context.logger.debug(`[${sourceName}] Copying ${source} to ${destination}`);
|
||||
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cp, copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, posix, relative } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
/**
|
||||
* A single matched asset — source file and its destination inside the
|
||||
* build output directory.
|
||||
*/
|
||||
export type CopyPair = { source: string; destination: string };
|
||||
|
||||
/**
|
||||
* Result of a single matcher's glob, grouped with the matcher that
|
||||
* produced it so callers can warn on empty matches.
|
||||
*/
|
||||
export type MatcherResult = {
|
||||
matcher: string;
|
||||
assets: CopyPair[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Glob a set of matchers relative to `cwd` and return pairs describing
|
||||
* where each matched file should be copied to under `destinationDir`.
|
||||
*
|
||||
* Relative paths are preserved under `destinationDir`. Leading `..`
|
||||
* segments (from `../shared/file.txt` style patterns) are stripped so
|
||||
* files always land inside the destination.
|
||||
*/
|
||||
export async function findFilesByMatchers(
|
||||
matchers: string[],
|
||||
destinationDir: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<MatcherResult[]> {
|
||||
const result: MatcherResult[] = [];
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const files = await glob({
|
||||
patterns: [matcher],
|
||||
cwd,
|
||||
ignore: options?.ignore ?? [],
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const assets: CopyPair[] = files.map((file) => {
|
||||
const pathInsideDestinationDir = relative(cwd, file)
|
||||
.split(posix.sep)
|
||||
.filter((p) => p !== "..")
|
||||
.join(posix.sep);
|
||||
return {
|
||||
source: file,
|
||||
destination: join(destinationDir, pathInsideDestinationDir),
|
||||
};
|
||||
});
|
||||
|
||||
result.push({ matcher, assets });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a single file, creating parent directories as needed.
|
||||
*/
|
||||
export async function copyFileEnsuringDir(source: string, destination: string): Promise<void> {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy every pair in the given matcher results. Parent directories are
|
||||
* created automatically. Returns the total number of files copied.
|
||||
*/
|
||||
export async function copyMatcherResults(
|
||||
matcherResults: MatcherResult[],
|
||||
onCopy?: (pair: CopyPair) => void
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const { assets } of matcherResults) {
|
||||
for (const pair of assets) {
|
||||
onCopy?.(pair);
|
||||
await copyFileEnsuringDir(pair.source, pair.destination);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory to another location. Preserves structure;
|
||||
* overwrites existing files at the destination.
|
||||
*
|
||||
* Used by the built-in skill bundler — we copy entire skill folders as a
|
||||
* unit, not file-by-file.
|
||||
*/
|
||||
export async function copyDirectoryRecursive(source: string, destination: string): Promise<void> {
|
||||
await mkdir(destination, { recursive: true });
|
||||
await cp(source, destination, { recursive: true, force: true });
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
"./v3/utils/omit": "./src/v3/utils/omit.ts",
|
||||
"./v3/utils/retries": "./src/v3/utils/retries.ts",
|
||||
"./v3/utils/structuredLogger": "./src/v3/utils/structuredLogger.ts",
|
||||
"./v3/test": "./src/v3/test/index.ts",
|
||||
"./v3/zodfetch": "./src/v3/zodfetch.ts",
|
||||
"./v3/zodMessageHandler": "./src/v3/zodMessageHandler.ts",
|
||||
"./v3/zodNamespace": "./src/v3/zodNamespace.ts",
|
||||
@@ -160,6 +161,9 @@
|
||||
],
|
||||
"v3/isomorphic": [
|
||||
"dist/commonjs/v3/isomorphic/index.d.ts"
|
||||
],
|
||||
"v3/test": [
|
||||
"dist/commonjs/v3/test/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -476,6 +480,17 @@
|
||||
"default": "./dist/commonjs/v3/utils/structuredLogger.js"
|
||||
}
|
||||
},
|
||||
"./v3/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"
|
||||
}
|
||||
},
|
||||
"./v3/zodfetch": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodfetch.ts",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
|
||||
import {
|
||||
PromptManifest,
|
||||
QueueManifest,
|
||||
SkillManifest,
|
||||
SkillMetadata,
|
||||
TaskManifest,
|
||||
WorkerManifest,
|
||||
} from "../schemas/index.js";
|
||||
import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js";
|
||||
|
||||
export interface ResourceCatalog {
|
||||
@@ -18,4 +25,7 @@ export interface ResourceCatalog {
|
||||
listPromptManifests(): Array<PromptManifest>;
|
||||
getPrompt(id: string): PromptMetadataWithFunctions | undefined;
|
||||
getPromptSchema(id: string): TaskSchema | undefined;
|
||||
registerSkillMetadata(skill: SkillMetadata): void;
|
||||
listSkillManifests(): Array<SkillManifest>;
|
||||
getSkillManifest(id: string): SkillManifest | undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const API_NAME = "resource-catalog";
|
||||
|
||||
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
|
||||
import {
|
||||
PromptManifest,
|
||||
QueueManifest,
|
||||
SkillManifest,
|
||||
SkillMetadata,
|
||||
TaskManifest,
|
||||
WorkerManifest,
|
||||
} from "../schemas/index.js";
|
||||
import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js";
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import { type ResourceCatalog } from "./catalog.js";
|
||||
@@ -93,6 +100,18 @@ export class ResourceCatalogAPI {
|
||||
return this.#getCatalog().getPromptSchema(id);
|
||||
}
|
||||
|
||||
public registerSkillMetadata(skill: SkillMetadata): void {
|
||||
this.#getCatalog().registerSkillMetadata(skill);
|
||||
}
|
||||
|
||||
public listSkillManifests(): Array<SkillManifest> {
|
||||
return this.#getCatalog().listSkillManifests();
|
||||
}
|
||||
|
||||
public getSkillManifest(id: string): SkillManifest | undefined {
|
||||
return this.#getCatalog().getSkillManifest(id);
|
||||
}
|
||||
|
||||
#getCatalog(): ResourceCatalog {
|
||||
return getGlobal(API_NAME) ?? NOOP_RESOURCE_CATALOG;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
|
||||
import {
|
||||
PromptManifest,
|
||||
QueueManifest,
|
||||
SkillManifest,
|
||||
SkillMetadata,
|
||||
TaskManifest,
|
||||
WorkerManifest,
|
||||
} from "../schemas/index.js";
|
||||
import { type PromptMetadataWithFunctions, type TaskMetadataWithFunctions, type TaskSchema } from "../types/index.js";
|
||||
import { ResourceCatalog } from "./catalog.js";
|
||||
|
||||
@@ -70,4 +77,16 @@ export class NoopResourceCatalog implements ResourceCatalog {
|
||||
getPromptSchema(id: string): TaskSchema | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerSkillMetadata(skill: SkillMetadata): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
listSkillManifests(): Array<SkillManifest> {
|
||||
return [];
|
||||
}
|
||||
|
||||
getSkillManifest(id: string): SkillManifest | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
PromptManifest,
|
||||
PromptMetadata,
|
||||
SkillManifest,
|
||||
SkillMetadata,
|
||||
TaskFileMetadata,
|
||||
TaskMetadata,
|
||||
TaskManifest,
|
||||
@@ -21,6 +23,8 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
private _promptSchemas: Map<string, TaskSchema> = new Map();
|
||||
private _currentFileContext?: Omit<TaskFileMetadata, "exportName">;
|
||||
private _queueMetadata: Map<string, QueueManifest> = new Map();
|
||||
private _skillMetadata: Map<string, SkillMetadata> = new Map();
|
||||
private _skillFileMetadata: Map<string, TaskFileMetadata> = new Map();
|
||||
|
||||
setCurrentFileContext(filePath: string, entryPoint: string) {
|
||||
this._currentFileContext = { filePath, entryPoint };
|
||||
@@ -86,25 +90,31 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
}
|
||||
|
||||
updateTaskMetadata(id: string, updates: Partial<TaskMetadataWithFunctions>): void {
|
||||
const { fns, schema, ...metadataUpdates } = updates;
|
||||
|
||||
const existingMetadata = this._taskMetadata.get(id);
|
||||
|
||||
if (existingMetadata) {
|
||||
if (existingMetadata && Object.keys(metadataUpdates).length > 0) {
|
||||
this._taskMetadata.set(id, {
|
||||
...existingMetadata,
|
||||
...updates,
|
||||
...metadataUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.fns) {
|
||||
if (fns) {
|
||||
const existingFunctions = this._taskFunctions.get(id);
|
||||
|
||||
if (existingFunctions) {
|
||||
this._taskFunctions.set(id, {
|
||||
...existingFunctions,
|
||||
...updates.fns,
|
||||
...fns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (schema) {
|
||||
this._taskSchemas.set(id, schema);
|
||||
}
|
||||
}
|
||||
|
||||
// Return all the tasks, without the functions
|
||||
@@ -233,6 +243,58 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
registerSkillMetadata(skill: SkillMetadata): void {
|
||||
if (!this._currentFileContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skill.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this._skillMetadata.get(skill.id);
|
||||
if (existing && existing.sourcePath !== skill.sourcePath) {
|
||||
console.warn(
|
||||
`Skill "${skill.id}" is defined twice with different paths. Keeping the first:\n` +
|
||||
` existing: ${existing.sourcePath}\n` +
|
||||
` ignored: ${skill.sourcePath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._skillFileMetadata.set(skill.id, {
|
||||
...this._currentFileContext,
|
||||
});
|
||||
this._skillMetadata.set(skill.id, skill);
|
||||
}
|
||||
|
||||
listSkillManifests(): Array<SkillManifest> {
|
||||
const result: Array<SkillManifest> = [];
|
||||
|
||||
for (const [id, metadata] of this._skillMetadata) {
|
||||
const fileMetadata = this._skillFileMetadata.get(id);
|
||||
if (!fileMetadata) continue;
|
||||
|
||||
result.push({
|
||||
...metadata,
|
||||
...fileMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getSkillManifest(id: string): SkillManifest | undefined {
|
||||
const metadata = this._skillMetadata.get(id);
|
||||
const fileMetadata = this._skillFileMetadata.get(id);
|
||||
if (!metadata || !fileMetadata) return undefined;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
...fileMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
disable() {
|
||||
// noop
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { unregisterGlobal } from "../utils/globals.js";
|
||||
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
|
||||
import { TaskContextAPI } from "./index.js";
|
||||
|
||||
const FAKE_CTX = {
|
||||
attempt: { id: "attempt_1", number: 1, startedAt: new Date(), status: "EXECUTING" as const },
|
||||
run: {
|
||||
id: "run_1",
|
||||
payload: undefined,
|
||||
payloadType: "application/json",
|
||||
context: undefined,
|
||||
createdAt: new Date(),
|
||||
tags: [],
|
||||
isTest: false,
|
||||
isReplay: false,
|
||||
startedAt: new Date(),
|
||||
durationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
},
|
||||
task: { id: "my-task", filePath: "src/trigger/task.ts", exportName: "myTask" },
|
||||
queue: { id: "queue_1", name: "default" },
|
||||
environment: { id: "env_1", slug: "dev", type: "DEVELOPMENT" as const },
|
||||
organization: { id: "org_1", slug: "acme", name: "Acme" },
|
||||
project: { id: "proj_1", ref: "proj_xyz", slug: "demo", name: "Demo" },
|
||||
machine: {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const FAKE_WORKER = { id: "worker_1", version: "1.0.0", contentHash: "abc" } as never;
|
||||
|
||||
describe("TaskContextAPI conversation id", () => {
|
||||
afterEach(() => {
|
||||
unregisterGlobal("task-context");
|
||||
TaskContextAPI.getInstance().setConversationId(undefined);
|
||||
});
|
||||
|
||||
it("returns no conversation attribute when setConversationId was never called", () => {
|
||||
const api = TaskContextAPI.getInstance();
|
||||
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
|
||||
|
||||
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes gen_ai.conversation.id after setConversationId", () => {
|
||||
const api = TaskContextAPI.getInstance();
|
||||
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
|
||||
|
||||
api.setConversationId("chat_123");
|
||||
|
||||
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBe("chat_123");
|
||||
});
|
||||
|
||||
it("clears the conversation attribute when called with undefined", () => {
|
||||
const api = TaskContextAPI.getInstance();
|
||||
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
|
||||
api.setConversationId("chat_123");
|
||||
|
||||
api.setConversationId(undefined);
|
||||
|
||||
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
|
||||
expect(api.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns no attributes when there is no task context", () => {
|
||||
const api = TaskContextAPI.getInstance();
|
||||
api.setConversationId("chat_123");
|
||||
|
||||
expect(api.attributes).toEqual({});
|
||||
});
|
||||
|
||||
it("clears conversation id when a new task context is registered (warm restart)", () => {
|
||||
const api = TaskContextAPI.getInstance();
|
||||
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
|
||||
api.setConversationId("chat_old");
|
||||
|
||||
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
|
||||
|
||||
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const API_NAME = "task-context";
|
||||
export class TaskContextAPI {
|
||||
private static _instance?: TaskContextAPI;
|
||||
private _runDisabled = false;
|
||||
private _conversationId?: string;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -45,6 +46,7 @@ export class TaskContextAPI {
|
||||
return {
|
||||
...this.contextAttributes,
|
||||
...this.workerAttributes,
|
||||
...this.conversationAttributes,
|
||||
[SemanticInternalAttributes.WARM_START]: !!this.isWarmStart,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +54,19 @@ export class TaskContextAPI {
|
||||
return {};
|
||||
}
|
||||
|
||||
get conversationAttributes(): Attributes {
|
||||
if (!this._conversationId) return {};
|
||||
return { [SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]: this._conversationId };
|
||||
}
|
||||
|
||||
get conversationId(): string | undefined {
|
||||
return this._conversationId;
|
||||
}
|
||||
|
||||
public setConversationId(conversationId: string | undefined): void {
|
||||
this._conversationId = conversationId || undefined;
|
||||
}
|
||||
|
||||
get resourceAttributes(): Attributes {
|
||||
if (this.ctx) {
|
||||
return {
|
||||
@@ -109,6 +124,11 @@ export class TaskContextAPI {
|
||||
|
||||
public setGlobalTaskContext(taskContext: TaskContext): boolean {
|
||||
this._runDisabled = false;
|
||||
// Each run boot re-registers the global; clear any conversation id
|
||||
// left over from a previous run on this warm-restarted process so
|
||||
// attributes don't bleed across runs that don't call
|
||||
// `setConversationId` themselves.
|
||||
this._conversationId = undefined;
|
||||
return registerGlobal(API_NAME, taskContext, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ export class TaskContextSpanProcessor implements SpanProcessor {
|
||||
if (!taskContext.isRunDisabled && taskContext.ctx.run.tags?.length) {
|
||||
span.setAttribute(SemanticInternalAttributes.RUN_TAGS, taskContext.ctx.run.tags);
|
||||
}
|
||||
|
||||
// Stamp `gen_ai.conversation.id` (OTel GenAI semantic convention)
|
||||
// directly on every span so it survives the OTLP ingest's `ctx.*`
|
||||
// strip and lands in the stored attributes column without a schema
|
||||
// migration.
|
||||
if (taskContext.conversationId) {
|
||||
span.setAttribute(
|
||||
SemanticInternalAttributes.GEN_AI_CONVERSATION_ID,
|
||||
taskContext.conversationId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPartialSpan(span) && !skipPartialSpan(span)) {
|
||||
@@ -178,6 +189,11 @@ export class TaskContextMetricExporter implements PushMetricExporter {
|
||||
contextAttrs[SemanticInternalAttributes.RUN_TAGS] = ctx.run.tags;
|
||||
}
|
||||
|
||||
if (taskContext.conversationId) {
|
||||
contextAttrs[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID] =
|
||||
taskContext.conversationId;
|
||||
}
|
||||
|
||||
const modified: ResourceMetrics = {
|
||||
resource: metrics.resource,
|
||||
scopeMetrics: metrics.scopeMetrics.map((scope) => ({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
runInMockTaskContext,
|
||||
type MockTaskContextDrivers,
|
||||
type MockTaskContextOptions,
|
||||
} from "./mock-task-context.js";
|
||||
export { TestInputStreamManager } from "./test-input-stream-manager.js";
|
||||
export { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js";
|
||||
export { TestRunMetadataManager } from "./test-run-metadata-manager.js";
|
||||
export { TestSessionStreamManager } from "./test-session-stream-manager.js";
|
||||
@@ -0,0 +1,294 @@
|
||||
import { inputStreams } from "../input-streams-api.js";
|
||||
import { realtimeStreams } from "../realtime-streams-api.js";
|
||||
import { sessionStreams } from "../session-streams-api.js";
|
||||
import { localsAPI } from "../locals-api.js";
|
||||
import { runMetadata } from "../run-metadata-api.js";
|
||||
import { taskContext } from "../task-context-api.js";
|
||||
import { lifecycleHooks } from "../lifecycle-hooks-api.js";
|
||||
import { runtime } from "../runtime-api.js";
|
||||
import { StandardLocalsManager } from "../locals/manager.js";
|
||||
import { StandardLifecycleHooksManager } from "../lifecycleHooks/manager.js";
|
||||
import { NoopRuntimeManager } from "../runtime/noopRuntimeManager.js";
|
||||
import { unregisterGlobal } from "../utils/globals.js";
|
||||
import type { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js";
|
||||
import type { LocalsKey } from "../locals/types.js";
|
||||
import type { SessionChannelIO } from "../sessionStreams/types.js";
|
||||
import { TestInputStreamManager } from "./test-input-stream-manager.js";
|
||||
import { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js";
|
||||
import { TestRunMetadataManager } from "./test-run-metadata-manager.js";
|
||||
import { TestSessionStreamManager } from "./test-session-stream-manager.js";
|
||||
|
||||
/**
|
||||
* Shallow-partial overrides applied on top of the default mock
|
||||
* `TaskRunContext`. Each sub-object is a partial of its real shape —
|
||||
* unset fields get sensible defaults.
|
||||
*/
|
||||
export type MockTaskRunContextOverrides = {
|
||||
task?: Partial<TaskRunContext["task"]>;
|
||||
attempt?: Partial<TaskRunContext["attempt"]>;
|
||||
run?: Partial<TaskRunContext["run"]>;
|
||||
machine?: Partial<TaskRunContext["machine"]>;
|
||||
queue?: Partial<TaskRunContext["queue"]>;
|
||||
environment?: Partial<TaskRunContext["environment"]>;
|
||||
organization?: Partial<TaskRunContext["organization"]>;
|
||||
project?: Partial<TaskRunContext["project"]>;
|
||||
batch?: TaskRunContext["batch"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for overriding parts of the mock task context.
|
||||
*/
|
||||
export type MockTaskContextOptions = {
|
||||
/** Overrides applied on top of the default mock `TaskRunContext`. */
|
||||
ctx?: MockTaskRunContextOverrides;
|
||||
/** Overrides applied on top of the default `ServerBackgroundWorker`. */
|
||||
worker?: Partial<ServerBackgroundWorker>;
|
||||
/** Whether this is a warm start. */
|
||||
isWarmStart?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drivers passed to the function running inside `runInMockTaskContext`.
|
||||
*/
|
||||
export type MockTaskContextDrivers = {
|
||||
/** Push data into input streams — simulates realtime input from outside the task. */
|
||||
inputs: {
|
||||
/**
|
||||
* Send `data` to the named input stream. Resolves when all `.on()`
|
||||
* handlers have run.
|
||||
*/
|
||||
send(streamId: string, data: unknown): Promise<void>;
|
||||
/** Resolve any pending `.once()` waiters with a timeout error. */
|
||||
close(streamId: string): void;
|
||||
};
|
||||
/** Inspect chunks written to output (realtime) streams. */
|
||||
outputs: {
|
||||
/** All chunks for a given stream, in the order they were written. */
|
||||
chunks<T = unknown>(streamId: string): T[];
|
||||
/** All chunks across every stream, keyed by stream id. */
|
||||
all(): Record<string, unknown[]>;
|
||||
/** Clear chunks for one stream, or all streams if no id is provided. */
|
||||
clear(streamId?: string): void;
|
||||
/**
|
||||
* Register a listener fired for every chunk written to any stream.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onWrite(listener: (streamId: string, chunk: unknown) => void): () => void;
|
||||
};
|
||||
/** Read or seed locals for the run. */
|
||||
locals: {
|
||||
/** Read a local set by either the task or `set()` below. */
|
||||
get<T>(key: LocalsKey<T>): T | undefined;
|
||||
/**
|
||||
* Pre-seed a local before the task runs. Use this for dependency
|
||||
* injection — e.g. supply a test database client that the agent's
|
||||
* hooks read via `locals.get()` instead of constructing the prod one.
|
||||
*/
|
||||
set<T>(key: LocalsKey<T>, value: T): void;
|
||||
};
|
||||
/**
|
||||
* Session-scoped channel drivers. The `.in` side is backed by a
|
||||
* {@link TestSessionStreamManager} installed as the `sessionStreams`
|
||||
* global — so the task's `session.in.on/once/peek/waitWithIdleTimeout`
|
||||
* calls receive records sent through this driver.
|
||||
*/
|
||||
sessions: {
|
||||
in: {
|
||||
/**
|
||||
* Send a record onto `session.in` for the given session. Resolves
|
||||
* pending `once()` waiters and fires all `on()` handlers.
|
||||
*/
|
||||
send(sessionId: string, data: unknown, io?: SessionChannelIO): Promise<void>;
|
||||
/** Close pending `once()` waiters with a timeout error. */
|
||||
close(sessionId: string, io?: SessionChannelIO): void;
|
||||
};
|
||||
};
|
||||
/** The mock `TaskRunContext` assembled from defaults + user overrides. */
|
||||
ctx: TaskRunContext;
|
||||
};
|
||||
|
||||
function defaultTaskRunContext(overrides?: MockTaskRunContextOverrides): TaskRunContext {
|
||||
return {
|
||||
task: {
|
||||
id: "test-task",
|
||||
filePath: "test-task.ts",
|
||||
...overrides?.task,
|
||||
},
|
||||
attempt: {
|
||||
number: 1,
|
||||
startedAt: new Date(),
|
||||
...overrides?.attempt,
|
||||
},
|
||||
run: {
|
||||
id: "run_test",
|
||||
tags: [],
|
||||
isTest: false,
|
||||
isReplay: false,
|
||||
createdAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
...overrides?.run,
|
||||
},
|
||||
machine: {
|
||||
name: "micro",
|
||||
cpu: 1,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0,
|
||||
...overrides?.machine,
|
||||
},
|
||||
queue: {
|
||||
name: "test-queue",
|
||||
id: "test-queue-id",
|
||||
...overrides?.queue,
|
||||
},
|
||||
environment: {
|
||||
id: "test-env-id",
|
||||
slug: "test-env",
|
||||
type: "DEVELOPMENT",
|
||||
...overrides?.environment,
|
||||
},
|
||||
organization: {
|
||||
id: "test-org-id",
|
||||
slug: "test-org",
|
||||
name: "Test Org",
|
||||
...overrides?.organization,
|
||||
},
|
||||
project: {
|
||||
id: "test-project-id",
|
||||
ref: "test-project-ref",
|
||||
slug: "test-project",
|
||||
name: "Test Project",
|
||||
...overrides?.project,
|
||||
},
|
||||
batch: overrides?.batch,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultWorker(overrides?: Partial<ServerBackgroundWorker>): ServerBackgroundWorker {
|
||||
return {
|
||||
id: "test-worker-id",
|
||||
version: "test-version",
|
||||
contentHash: "test-content-hash",
|
||||
engine: "V2",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function inside a fully mocked task runtime context.
|
||||
*
|
||||
* Installs in-memory test managers for `locals`, `inputStreams`,
|
||||
* `realtimeStreams`, `lifecycleHooks`, and `runtime`, sets a mock
|
||||
* `TaskContext`, and tears everything down when the function returns.
|
||||
*
|
||||
* Inside the function, any code that reads from `locals`, `inputStreams`,
|
||||
* `realtimeStreams`, or `taskContext.ctx` will see the mock context —
|
||||
* so you can directly invoke the internal `run` function of any task
|
||||
* (including `chat.agent`) without hitting the Trigger.dev runtime.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
|
||||
*
|
||||
* await runInMockTaskContext(
|
||||
* async ({ inputs, outputs, ctx }) => {
|
||||
* // Fire an input stream from the "outside"
|
||||
* setTimeout(() => {
|
||||
* inputs.send("chat-messages", { messages: [], chatId: "c1" });
|
||||
* }, 0);
|
||||
*
|
||||
* // Run task code that reads from inputStreams.once(...)
|
||||
* await myTask.fns.run(payload, { ctx, signal: new AbortController().signal });
|
||||
*
|
||||
* // Inspect chunks written to the output stream
|
||||
* expect(outputs.chunks("chat")).toContainEqual({ type: "text-delta", delta: "hi" });
|
||||
* },
|
||||
* { ctx: { run: { id: "run_abc" } } }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function runInMockTaskContext<T>(
|
||||
fn: (drivers: MockTaskContextDrivers) => T | Promise<T>,
|
||||
options?: MockTaskContextOptions
|
||||
): Promise<T> {
|
||||
const ctx = defaultTaskRunContext(options?.ctx);
|
||||
const worker = defaultWorker(options?.worker);
|
||||
|
||||
const localsManager = new StandardLocalsManager();
|
||||
const lifecycleManager = new StandardLifecycleHooksManager();
|
||||
const runtimeManager = new NoopRuntimeManager();
|
||||
const metadataManager = new TestRunMetadataManager();
|
||||
const inputManager = new TestInputStreamManager();
|
||||
const outputManager = new TestRealtimeStreamsManager();
|
||||
const sessionStreamManager = new TestSessionStreamManager();
|
||||
|
||||
// Unregister any previously-installed managers so `setGlobal*` wins —
|
||||
// `registerGlobal` returns false silently if an entry already exists.
|
||||
unregisterGlobal("locals");
|
||||
unregisterGlobal("lifecycle-hooks");
|
||||
unregisterGlobal("runtime");
|
||||
unregisterGlobal("run-metadata");
|
||||
unregisterGlobal("input-streams");
|
||||
unregisterGlobal("realtime-streams");
|
||||
unregisterGlobal("session-streams");
|
||||
unregisterGlobal("task-context");
|
||||
|
||||
localsAPI.setGlobalLocalsManager(localsManager);
|
||||
lifecycleHooks.setGlobalLifecycleHooksManager(lifecycleManager);
|
||||
runtime.setGlobalRuntimeManager(runtimeManager);
|
||||
runMetadata.setGlobalManager(metadataManager);
|
||||
inputStreams.setGlobalManager(inputManager);
|
||||
realtimeStreams.setGlobalManager(outputManager);
|
||||
sessionStreams.setGlobalManager(sessionStreamManager);
|
||||
taskContext.setGlobalTaskContext({
|
||||
ctx,
|
||||
worker,
|
||||
isWarmStart: options?.isWarmStart ?? false,
|
||||
});
|
||||
|
||||
const drivers: MockTaskContextDrivers = {
|
||||
inputs: {
|
||||
send: (streamId, data) => inputManager.__sendFromTest(streamId, data),
|
||||
close: (streamId) => inputManager.__closeFromTest(streamId),
|
||||
},
|
||||
outputs: {
|
||||
chunks: (streamId) => outputManager.__chunksFromTest(streamId),
|
||||
all: () => outputManager.__allChunksFromTest(),
|
||||
clear: (streamId) => outputManager.__clearFromTest(streamId),
|
||||
onWrite: (listener) => outputManager.onWrite(listener),
|
||||
},
|
||||
locals: {
|
||||
get: <TValue>(key: LocalsKey<TValue>) => localsManager.getLocal(key),
|
||||
set: <TValue>(key: LocalsKey<TValue>, value: TValue) =>
|
||||
localsManager.setLocal(key, value),
|
||||
},
|
||||
sessions: {
|
||||
in: {
|
||||
send: (sessionId, data, io = "in") =>
|
||||
sessionStreamManager.__sendFromTest(sessionId, io, data),
|
||||
close: (sessionId, io = "in") =>
|
||||
sessionStreamManager.__closeFromTest(sessionId, io),
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
};
|
||||
|
||||
try {
|
||||
return await fn(drivers);
|
||||
} finally {
|
||||
localsAPI.disable();
|
||||
lifecycleHooks.disable();
|
||||
runtime.disable();
|
||||
// taskContext.disable() only sets a flag — unregister the global so
|
||||
// `taskContext.ctx` returns undefined after the harness returns.
|
||||
unregisterGlobal("task-context");
|
||||
unregisterGlobal("input-streams");
|
||||
unregisterGlobal("realtime-streams");
|
||||
unregisterGlobal("session-streams");
|
||||
unregisterGlobal("run-metadata");
|
||||
localsManager.reset();
|
||||
inputManager.reset();
|
||||
outputManager.reset();
|
||||
sessionStreamManager.reset();
|
||||
metadataManager.reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runInMockTaskContext } from "../src/v3/test/index.js";
|
||||
import { inputStreams } from "../src/v3/input-streams-api.js";
|
||||
import { realtimeStreams } from "../src/v3/realtime-streams-api.js";
|
||||
import { locals } from "../src/v3/locals-api.js";
|
||||
import { taskContext } from "../src/v3/task-context-api.js";
|
||||
|
||||
describe("runInMockTaskContext", () => {
|
||||
it("installs a mock TaskRunContext with sensible defaults", async () => {
|
||||
await runInMockTaskContext(async ({ ctx }) => {
|
||||
expect(taskContext.ctx).toBeDefined();
|
||||
expect(taskContext.ctx?.run.id).toBe("run_test");
|
||||
expect(taskContext.ctx?.task.id).toBe("test-task");
|
||||
expect(ctx.run.id).toBe("run_test");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies ctx overrides on top of defaults", async () => {
|
||||
await runInMockTaskContext(
|
||||
async ({ ctx }) => {
|
||||
expect(ctx.run.id).toBe("run_abc");
|
||||
expect(ctx.task.id).toBe("my-chat-agent");
|
||||
// Unspecified fields still use defaults
|
||||
expect(ctx.queue.id).toBe("test-queue-id");
|
||||
},
|
||||
{
|
||||
ctx: {
|
||||
run: { id: "run_abc" },
|
||||
task: { id: "my-chat-agent", filePath: "chat.ts" },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates locals from the surrounding context", async () => {
|
||||
const key = locals.create<{ count: number }>("test.counter");
|
||||
|
||||
await runInMockTaskContext(async ({ locals: inspect }) => {
|
||||
expect(inspect.get(key)).toBeUndefined();
|
||||
locals.set(key, { count: 1 });
|
||||
expect(inspect.get(key)).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
// After the harness exits, the locals should be gone
|
||||
expect(locals.get(key)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tears down the task context after fn returns", async () => {
|
||||
await runInMockTaskContext(async () => {
|
||||
expect(taskContext.ctx).toBeDefined();
|
||||
});
|
||||
|
||||
expect(taskContext.ctx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tears down even when fn throws", async () => {
|
||||
await expect(
|
||||
runInMockTaskContext(async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(taskContext.ctx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the value returned by fn", async () => {
|
||||
const result = await runInMockTaskContext(async () => "hello");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
describe("input streams driver", () => {
|
||||
it("resolves inputStreams.once() when test sends data", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const pending = inputStreams.once("chat-messages");
|
||||
setTimeout(() => inputs.send("chat-messages", { hello: "world" }), 0);
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.output).toEqual({ hello: "world" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fires inputStreams.on() handlers when test sends data", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const received: unknown[] = [];
|
||||
inputStreams.on("chat-messages", (data) => {
|
||||
received.push(data);
|
||||
});
|
||||
|
||||
await inputs.send("chat-messages", { n: 1 });
|
||||
await inputs.send("chat-messages", { n: 2 });
|
||||
|
||||
expect(received).toEqual([{ n: 1 }, { n: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("fires multiple on() handlers on the same stream", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const a: unknown[] = [];
|
||||
const b: unknown[] = [];
|
||||
inputStreams.on("chat-messages", (data) => a.push(data));
|
||||
inputStreams.on("chat-messages", (data) => b.push(data));
|
||||
|
||||
await inputs.send("chat-messages", "hi");
|
||||
expect(a).toEqual(["hi"]);
|
||||
expect(b).toEqual(["hi"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("off() unsubscribes a handler", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const received: unknown[] = [];
|
||||
const sub = inputStreams.on("chat-messages", (data) => received.push(data));
|
||||
|
||||
await inputs.send("chat-messages", 1);
|
||||
sub.off();
|
||||
await inputs.send("chat-messages", 2);
|
||||
|
||||
expect(received).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
it("times out once() after timeoutMs", async () => {
|
||||
await runInMockTaskContext(async () => {
|
||||
const result = await inputStreams.once("chat-messages", { timeoutMs: 10 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("peek() returns the latest sent value", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
expect(inputStreams.peek("chat-messages")).toBeUndefined();
|
||||
await inputs.send("chat-messages", { latest: true });
|
||||
expect(inputStreams.peek("chat-messages")).toEqual({ latest: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("close() rejects pending once() waiters with a timeout error", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const pending = inputStreams.once("chat-messages");
|
||||
inputs.close("chat-messages");
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves multiple concurrent once() waiters from a single send", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const a = inputStreams.once("chat-messages");
|
||||
const b = inputStreams.once("chat-messages");
|
||||
await inputs.send("chat-messages", "shared");
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
expect(ra.ok && ra.output).toBe("shared");
|
||||
expect(rb.ok && rb.output).toBe("shared");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("realtime streams driver", () => {
|
||||
it("collects chunks from realtimeStreams.append()", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "chunk-1" as unknown as BodyInit);
|
||||
await realtimeStreams.append("chat", "chunk-2" as unknown as BodyInit);
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["chunk-1", "chunk-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("collects chunks from realtimeStreams.pipe()", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
const source = (async function* () {
|
||||
yield "a";
|
||||
yield "b";
|
||||
yield "c";
|
||||
})();
|
||||
|
||||
const instance = realtimeStreams.pipe("chat", source);
|
||||
|
||||
// Drain the returned stream — that's what feeds the buffer
|
||||
for await (const _ of instance.stream) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("separates chunks by stream id", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "a" as unknown as BodyInit);
|
||||
await realtimeStreams.append("stop", "halt" as unknown as BodyInit);
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["a"]);
|
||||
expect(outputs.chunks("stop")).toEqual(["halt"]);
|
||||
expect(outputs.all()).toEqual({ chat: ["a"], stop: ["halt"] });
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() empties one stream or all streams", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "a" as unknown as BodyInit);
|
||||
await realtimeStreams.append("stop", "halt" as unknown as BodyInit);
|
||||
|
||||
outputs.clear("chat");
|
||||
expect(outputs.chunks("chat")).toEqual([]);
|
||||
expect(outputs.chunks("stop")).toEqual(["halt"]);
|
||||
|
||||
outputs.clear();
|
||||
expect(outputs.chunks("stop")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("tears down input/output managers so consecutive calls are isolated", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
await inputs.send("chat-messages", "first-run");
|
||||
});
|
||||
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
expect(outputs.chunks("chat-messages")).toEqual([]);
|
||||
// inputs.peek should NOT see "first-run" from the prior harness
|
||||
expect(inputStreams.peek("chat-messages")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StandardResourceCatalog } from "../src/v3/resource-catalog/standardResourceCatalog.js";
|
||||
|
||||
describe("StandardResourceCatalog — skills", () => {
|
||||
it("registers and lists a skill manifest", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf-processing", sourcePath: "./skills/pdf-processing" });
|
||||
|
||||
const manifests = catalog.listSkillManifests();
|
||||
expect(manifests).toHaveLength(1);
|
||||
expect(manifests[0]).toMatchObject({
|
||||
id: "pdf-processing",
|
||||
sourcePath: "./skills/pdf-processing",
|
||||
filePath: "trigger/chat.ts",
|
||||
entryPoint: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSkillManifest returns the registered skill", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
catalog.registerSkillMetadata({ id: "a", sourcePath: "./skills/a" });
|
||||
|
||||
expect(catalog.getSkillManifest("a")?.sourcePath).toBe("./skills/a");
|
||||
expect(catalog.getSkillManifest("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips registration without a file context", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
|
||||
expect(catalog.listSkillManifests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("warns and ignores when the same id is registered with a different path", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/other-pdf" });
|
||||
|
||||
const manifests = catalog.listSkillManifests();
|
||||
expect(manifests).toHaveLength(1);
|
||||
expect(manifests[0]?.sourcePath).toBe("./skills/pdf");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("defined twice"));
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("re-registering the same id + path is idempotent", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
|
||||
expect(catalog.listSkillManifests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers multiple distinct skills", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "researcher", sourcePath: "./skills/researcher" });
|
||||
|
||||
expect(catalog.listSkillManifests().map((s) => s.id).sort()).toEqual(["pdf", "researcher"]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,12 @@
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/v3/index.ts",
|
||||
"./v3": "./src/v3/index.ts",
|
||||
"./ai": "./src/v3/ai.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"
|
||||
@@ -37,6 +42,21 @@
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -63,11 +83,13 @@
|
||||
"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.0",
|
||||
"ai": "^6.0.116",
|
||||
"encoding": "^0.1.13",
|
||||
"rimraf": "^6.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
@@ -76,12 +98,16 @@
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.0.0 || ^4.0.0",
|
||||
"ai": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
||||
"ai": "^5.0.0 || ^6.0.0",
|
||||
"react": "^18.0 || ^19.0",
|
||||
"zod": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ai": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -121,6 +147,61 @@
|
||||
"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",
|
||||
|
||||
@@ -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,200 @@
|
||||
/**
|
||||
* 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 { ModelMessage, 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;
|
||||
|
||||
/**
|
||||
* The wire payload shape sent by `TriggerChatTransport`.
|
||||
* Uses `metadata` to match the AI SDK's `ChatRequestOptions` field name.
|
||||
*
|
||||
* Slim wire: at most ONE message per record. The agent runtime
|
||||
* reconstructs prior history at run boot from a durable S3 snapshot +
|
||||
* `session.out` replay (or `hydrateMessages` if registered). The wire is
|
||||
* delta-only — see plan `vivid-humming-bonbon.md`.
|
||||
*/
|
||||
export type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
|
||||
/**
|
||||
* The single message being delivered on this trigger. Set for:
|
||||
* - `submit-message`: the new user message OR a tool-approval-responded
|
||||
* assistant message (with `state: "approval-responded"` tool parts).
|
||||
* - `regenerate-message`: omitted (the agent slices its own history).
|
||||
* - `preload` / `close` / `action`: omitted.
|
||||
* - `handover-prepare`: omitted (use `headStartMessages` instead).
|
||||
*/
|
||||
message?: TMessage;
|
||||
/**
|
||||
* Bespoke escape hatch for `chat.headStart`. The customer's HTTP route
|
||||
* handler ships full `UIMessage[]` history at the very first turn — before
|
||||
* any snapshot exists. The route handler isn't subject to the
|
||||
* `MAX_APPEND_BODY_BYTES` cap on `/in/append` because it goes through the
|
||||
* customer's own HTTP endpoint. Used ONLY by `trigger: "handover-prepare"`.
|
||||
* Ignored on every other trigger.
|
||||
*/
|
||||
headStartMessages?: TMessage[];
|
||||
chatId: string;
|
||||
trigger:
|
||||
| "submit-message"
|
||||
| "regenerate-message"
|
||||
| "preload"
|
||||
| "close"
|
||||
| "action"
|
||||
/**
|
||||
* The customer's `chat.handover` route handler kicked us off in
|
||||
* parallel with the first-turn `streamText` running in the warm
|
||||
* Next.js process. The run sits idle on `session.in` waiting for
|
||||
* a `kind: "handover"` (continue from tool execution) or
|
||||
* `kind: "handover-skip"` (handler finished pure-text, exit
|
||||
* cleanly). See `chat.handover` in `@trigger.dev/sdk/chat-server`.
|
||||
*/
|
||||
| "handover-prepare";
|
||||
messageId?: string;
|
||||
metadata?: TMetadata;
|
||||
/** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */
|
||||
action?: unknown;
|
||||
/** Whether this run is continuing an existing chat whose previous run ended. */
|
||||
continuation?: boolean;
|
||||
/** The run ID of the previous run (only set when `continuation` is true). */
|
||||
previousRunId?: string;
|
||||
/** Override idle timeout for this run (seconds). Set by transport.preload(). */
|
||||
idleTimeoutInSeconds?: number;
|
||||
/**
|
||||
* The friendlyId of the Session primitive backing this chat. The
|
||||
* transport opens (or lazy-creates) the session with
|
||||
* `externalId = chatId` on first message, then sends this friendlyId
|
||||
* through to the run so the agent can attach to `.in` / `.out`
|
||||
* without needing to round-trip through the control plane again.
|
||||
* Optional for backward-compat while the migration is in flight;
|
||||
* required once the legacy run-scoped stream path is removed.
|
||||
*/
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One chunk on the chat input stream. `kind` discriminates the variants —
|
||||
* a single ordered stream now carries all the signals the old three-stream
|
||||
* split did (`chat-messages`, `chat-stop`, plus action messages piggybacked
|
||||
* on `chat-messages`).
|
||||
*/
|
||||
export type ChatInputChunk<TMessage extends UIMessage = UIMessage, TMetadata = unknown> =
|
||||
| {
|
||||
kind: "message";
|
||||
/**
|
||||
* Full wire payload for a new user message or regeneration. Mirrors
|
||||
* what the legacy `chat-messages` input stream carried.
|
||||
*/
|
||||
payload: ChatTaskWirePayload<TMessage, TMetadata>;
|
||||
}
|
||||
| {
|
||||
kind: "stop";
|
||||
/** Optional human-readable reason. Maps to the legacy `chat-stop` record. */
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Sent by `chat.headStart` when the customer's first-turn
|
||||
* `streamText` finishes. The agent run (currently parked in
|
||||
* `handover-prepare`) wakes, seeds its accumulators with
|
||||
* `partialAssistantMessage`, and runs the normal turn loop
|
||||
* (`onChatStart` → `onTurnStart` → … → `onTurnComplete`).
|
||||
*
|
||||
* What happens after that depends on `isFinal`:
|
||||
*
|
||||
* - `isFinal: false` — step 1 ended with `finishReason:
|
||||
* "tool-calls"`. The partial carries the assistant's
|
||||
* tool-call(s) wrapped in AI SDK's tool-approval round. The
|
||||
* agent's `streamText` runs the approved tools and continues
|
||||
* from step 2.
|
||||
* - `isFinal: true` — step 1 ended pure-text (no tool calls).
|
||||
* The partial carries the final assistant text. The agent
|
||||
* skips the LLM call entirely (the response is already
|
||||
* complete on the customer side) and runs `onTurnComplete`
|
||||
* with the partial as `responseMessage` so persistence and
|
||||
* any post-turn work fire normally.
|
||||
*/
|
||||
kind: "handover";
|
||||
/** Customer's step-1 response messages (ModelMessage form). */
|
||||
partialAssistantMessage: ModelMessage[];
|
||||
/**
|
||||
* The UI messageId the customer's handler used for its step-1
|
||||
* assistant message. The agent reuses this so any post-handover
|
||||
* chunks (tool-output-available, step-2 text, data-* parts
|
||||
* written by hooks) merge into the SAME assistant message on
|
||||
* the browser side instead of starting a new one.
|
||||
*/
|
||||
messageId?: string;
|
||||
/**
|
||||
* Whether the customer's step 1 is the final response. See
|
||||
* `kind` description above for the two branches.
|
||||
*/
|
||||
isFinal: boolean;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Sent by `chat.headStart` only when the customer's handler
|
||||
* ABORTS before producing a finishReason (e.g., dispatch error,
|
||||
* stream cancelled before any tokens). The agent run exits
|
||||
* cleanly without firing turn hooks. Normal pure-text and
|
||||
* tool-call finishes go through `kind: "handover"` with the
|
||||
* appropriate `isFinal` flag.
|
||||
*/
|
||||
kind: "handover-skip";
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
+8721
-33
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,17 @@ type PublicTokenPermissionProperties = {
|
||||
* 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. `trigger:sessions:{id}` permits
|
||||
* triggering new runs on the session.
|
||||
*/
|
||||
sessions?: string | string[];
|
||||
};
|
||||
|
||||
export type PublicTokenPermissions = {
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* 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";
|
||||
import { readUIMessageStream } from "ai";
|
||||
import { ApiClient, SSEStreamSubscription, apiClientManager } from "@trigger.dev/core/v3";
|
||||
import type { ChatInputChunk, ChatTaskWirePayload } 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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// ─── 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 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;
|
||||
|
||||
// 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. `regenerate-message` omits `message` (the agent slices its own
|
||||
// history). See plan vivid-humming-bonbon.
|
||||
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"
|
||||
? (messages.at(-1) as UIMessage | undefined)
|
||||
: undefined;
|
||||
const payload: ChatTaskWirePayload = {
|
||||
...(lastIfSubmit ? { message: lastIfSubmit } : {}),
|
||||
chatId: this.chatId,
|
||||
trigger: triggerType,
|
||||
metadata: this.clientData,
|
||||
} as ChatTaskWirePayload;
|
||||
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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 {
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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;
|
||||
const api = this.createApiClient();
|
||||
await api
|
||||
.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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> {
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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> {
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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 {
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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 {
|
||||
const api = this.createApiClient();
|
||||
await api.appendToSessionStream(
|
||||
this.chatId,
|
||||
"in",
|
||||
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 createApiClient(): ApiClient {
|
||||
const baseURL = apiClientManager.baseURL ?? "https://api.trigger.dev";
|
||||
const accessToken = apiClientManager.accessToken ?? "";
|
||||
return new ApiClient(baseURL, accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 baseURL = apiClientManager.baseURL ?? "https://api.trigger.dev";
|
||||
const accessToken = apiClientManager.accessToken ?? "";
|
||||
const onTurnComplete = this.onTurnComplete;
|
||||
const chatId = this.chatId;
|
||||
|
||||
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;
|
||||
const api = new ApiClient(baseURL, accessToken);
|
||||
api
|
||||
.appendToSessionStream(
|
||||
chatId,
|
||||
"in",
|
||||
serializeInputChunk({ kind: "stop" })
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
internalAbort.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
const streamUrl = `${baseURL}/realtime/v1/sessions/${encodeURIComponent(chatId)}/out`;
|
||||
|
||||
return new ReadableStream<UIMessageChunk>({
|
||||
start: async (controller) => {
|
||||
try {
|
||||
const subscription = new SSEStreamSubscription(streamUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: combinedSignal,
|
||||
timeoutInSeconds: this.streamTimeoutSeconds,
|
||||
lastEventId: state.lastEventId,
|
||||
});
|
||||
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;
|
||||
|
||||
// Session records arrive as raw JSON strings (the server
|
||||
// wraps `{data, id}` on S2). Parse back into objects so
|
||||
// the control-flow below can inspect chunk.type.
|
||||
let chunkObj: Record<string, unknown> | null = null;
|
||||
if (value.chunk != null) {
|
||||
if (typeof value.chunk === "string") {
|
||||
try {
|
||||
chunkObj = JSON.parse(value.chunk) as Record<string, unknown>;
|
||||
} catch {
|
||||
chunkObj = null;
|
||||
}
|
||||
} else if (typeof value.chunk === "object") {
|
||||
chunkObj = value.chunk as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
if (!chunkObj) continue;
|
||||
|
||||
const chunk = chunkObj;
|
||||
|
||||
if (state.skipToTurnComplete) {
|
||||
if (chunk.type === "trigger:turn-complete") {
|
||||
state.skipToTurnComplete = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.type === "trigger: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 (chunk.type === "trigger: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;
|
||||
}
|
||||
|
||||
controller.enqueue(chunk as unknown 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,457 @@
|
||||
"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 };
|
||||
|
||||
/**
|
||||
* 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 } = options;
|
||||
useEffect(() => {
|
||||
ref.current?.setOnSessionChange(onSessionChange);
|
||||
}, [onSessionChange]);
|
||||
|
||||
// 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, sendMessage, metadata } = options;
|
||||
|
||||
// Internal state: track messages with their mode
|
||||
type InternalMessage = TUIMessage & { _mode: "steering" | "queued" };
|
||||
const [pendingMsgs, setPendingMsgs] = useState<InternalMessage[]>([]);
|
||||
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;
|
||||
}
|
||||
promotedIdsRef.current.add(id);
|
||||
|
||||
setPendingMsgs((prev) => {
|
||||
const msg = prev.find((m) => m.id === id);
|
||||
if (!msg || msg._mode !== "queued") return prev;
|
||||
transport.sendPendingMessage(chatId, msg, metadata);
|
||||
return 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,617 @@
|
||||
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("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.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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,915 @@
|
||||
/**
|
||||
* Server-side helpers for the `chat.agent` head-start flow — a
|
||||
* customer's warm process (Next.js route handler, Express, etc.)
|
||||
* gets the conversation moving while the heavy chat.agent run boots
|
||||
* in parallel. Mid-turn, ownership of the durable stream hands over
|
||||
* to the agent.
|
||||
*
|
||||
* The `chat.headStart({ agentId, run })` entry point returns a
|
||||
* Next.js-style POST handler. Inside the customer's `run` callback
|
||||
* they call `streamText` themselves, spreading
|
||||
* `chat.toStreamTextOptions({ tools })` to inherit handover wiring.
|
||||
* The handler runs `streamText` step 1 in the customer's process
|
||||
* while the chat.agent run boots in parallel; on `tool-calls` the
|
||||
* agent run picks up tool execution and continues, on pure-text the
|
||||
* agent run exits clean without an LLM call.
|
||||
*
|
||||
* Two-layer naming: customer-facing surface is "head start"
|
||||
* (describes the *benefit* — fast first-turn TTFC). The internal
|
||||
* protocol still uses "handover" (describes the *mechanism* — the
|
||||
* conversation hands off mid-turn from the warm process to the
|
||||
* agent). Customers see `chat.headStart`, `HeadStartSession`, etc.
|
||||
* The wire format and run-loop locals stay on `handover` /
|
||||
* `handover-prepare` / `handover-skip`.
|
||||
*
|
||||
* Cooperative ordering only — handler stops writing to `session.out`
|
||||
* before sending the `handover` chunk on `session.in`. No S2 fencing.
|
||||
*
|
||||
* ⚠️ HARD CONSTRAINT — bundle isolation
|
||||
*
|
||||
* This module is the customer-facing boundary for the route handler.
|
||||
* The whole TTFC win comes from the customer's process being
|
||||
* lightweight while the heavy agent run boots in parallel. **The
|
||||
* route-handler bundle must not include heavy tool execute deps**:
|
||||
* E2B, puppeteer/playwright, native bindings, the trigger SDK
|
||||
* runtime, turndown, image processing libs, anything that pulls
|
||||
* weight or pulls `node:` builtins.
|
||||
*
|
||||
* "Schema-only" tools must live in a module that imports only `ai`
|
||||
* (for `tool()`) and `zod`. The agent task module imports those
|
||||
* schemas and adds execute fns elsewhere — that's where the heavy
|
||||
* deps live, and it's never reached by the route handler bundle.
|
||||
*
|
||||
* Runtime "strip executes" helpers (anything that takes a tool
|
||||
* catalog with executes and removes them) DO NOT solve this. The
|
||||
* import chain is resolved at bundle/build time, so importing the
|
||||
* full catalog drags every dep in regardless of what the SDK does
|
||||
* with the value at runtime.
|
||||
*
|
||||
* IMPORTANT (internal): this module must NOT import from `./ai.ts`.
|
||||
* `ai.ts` statically imports `agentSkillsRuntime` (which uses `node:`
|
||||
* builtins unfit for some serverless runtimes) and the heavy task
|
||||
* runtime. Allowed imports: `./ai-shared.js`, `./chat-client.js`,
|
||||
* `@trigger.dev/core/v3` (api client), `ai` (types + lightweight
|
||||
* helpers like `stepCountIs` / `convertToModelMessages`).
|
||||
*/
|
||||
|
||||
import { ApiClient, SessionStreamInstance, apiClientManager } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
generateId as generateAssistantMessageId,
|
||||
stepCountIs,
|
||||
type ModelMessage,
|
||||
type StreamTextResult,
|
||||
type Tool,
|
||||
type UIMessage,
|
||||
type UIMessageChunk,
|
||||
} from "ai";
|
||||
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type HeadStartRunArgs<TTools extends Record<string, Tool>> = {
|
||||
/** User messages parsed from the incoming request. */
|
||||
messages: UIMessage[];
|
||||
/** Aborts when the request closes or the SDK times out the handover. */
|
||||
signal: AbortSignal;
|
||||
/** Helper exposing `toStreamTextOptions(...)` and a session escape hatch. */
|
||||
chat: HeadStartChatHelper<TTools>;
|
||||
};
|
||||
|
||||
export type HeadStartChatHelper<TTools extends Record<string, Tool>> = {
|
||||
/**
|
||||
* Spread into the customer's `streamText` call to inherit handover
|
||||
* wiring. Returns options for:
|
||||
*
|
||||
* - `messages` — converted from the wire payload's UIMessages
|
||||
* - `tools` — the customer's tool set (typically schema-only — see
|
||||
* the bundle-isolation note in this module's header)
|
||||
* - `abortSignal` — combined request-lifecycle + idle timeout
|
||||
* - `stopWhen` — `stepCountIs(1)`. Step 1 only. The agent run picks
|
||||
* up tool execution and step 2+ after the handover signal.
|
||||
*
|
||||
* Customer adds `model`, `system`, `providerOptions`, etc. on top.
|
||||
* The customer keeps full control of the `streamText` call shape;
|
||||
* this helper just hands back the options the SDK needs to own.
|
||||
*
|
||||
* The customer COULD override any of these by re-setting them after
|
||||
* the spread, but doing so for `stopWhen` / `messages` /
|
||||
* `abortSignal` will break the handover protocol. The intent is
|
||||
* that customers spread first, then add only their own keys.
|
||||
*/
|
||||
toStreamTextOptions<TOpts extends Record<string, unknown> = Record<string, unknown>>(opts?: {
|
||||
tools?: TTools;
|
||||
}): TOpts;
|
||||
/** Lower-level escape hatch with manual `out` / `in` / dispatch primitives. */
|
||||
session: HeadStartSession;
|
||||
};
|
||||
|
||||
export type HeadStartSession = {
|
||||
readonly chatId: string;
|
||||
/**
|
||||
* Tees a UIMessage stream into `session.out` for durability/resume,
|
||||
* fire-and-forget. Returns a passthrough that the caller can use as
|
||||
* the HTTP response body.
|
||||
*/
|
||||
tee(
|
||||
stream: ReadableStream<UIMessageChunk>
|
||||
): ReadableStream<UIMessageChunk>;
|
||||
/**
|
||||
* Awaits `result.finishReason` and dispatches `handover` (with the
|
||||
* partial assistant ModelMessages) or `handover-skip`.
|
||||
*/
|
||||
handoverWhenDone(result: StreamTextResult<any, any>): Promise<void>;
|
||||
/**
|
||||
* Sugar over `tee` + `handoverWhenDone` + standard SSE response.
|
||||
* Returns a `Response` with `Content-Type: text/event-stream` whose
|
||||
* body is the teed stream.
|
||||
*/
|
||||
handoverResponse(result: StreamTextResult<any, any>): Response;
|
||||
/**
|
||||
* Manually dispatch the `handover` signal on `session.in`.
|
||||
*
|
||||
* - `isFinal: true` — the partial assistant message IS the response.
|
||||
* The agent runs `onChatStart` / `onTurnStart` / `onTurnComplete`
|
||||
* against it but skips the LLM call. Use for pure-text replies.
|
||||
* - `isFinal: false` — the partial assistant message ends with
|
||||
* pending tool calls. The agent executes them and then runs a
|
||||
* step-2 LLM call to produce the final response.
|
||||
*
|
||||
* `messageId` lets the caller carry a stable assistant message id
|
||||
* across the handover boundary so the browser merges step 1 and
|
||||
* step 2 into the same `UIMessage`.
|
||||
*/
|
||||
handover(args: {
|
||||
partialAssistantMessage: ModelMessage[];
|
||||
isFinal: boolean;
|
||||
messageId?: string;
|
||||
}): Promise<void>;
|
||||
/** Manually dispatch the `handover-skip` signal on `session.in`. */
|
||||
handoverSkip(): Promise<void>;
|
||||
};
|
||||
|
||||
export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
|
||||
/** The `chat.agent({ id })` of the agent we're handing off to. */
|
||||
agentId: string;
|
||||
/**
|
||||
* Customer's first-turn implementation. Receives `messages`,
|
||||
* `signal`, and a `chat` helper. Should call `streamText` with
|
||||
* `...chat.toStreamTextOptions({ tools })` and return the
|
||||
* `StreamTextResult`.
|
||||
*/
|
||||
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>;
|
||||
/**
|
||||
* Seconds the agent run waits for the handover signal before
|
||||
* exiting. Defaults to 60.
|
||||
*/
|
||||
idleTimeoutInSeconds?: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const chat = {
|
||||
/**
|
||||
* Returns a Next.js-style POST handler for the chat.agent
|
||||
* head-start flow. Customer mounts it as
|
||||
* `export const { POST } = chat.headStart({...})` (or
|
||||
* `export const POST = chat.headStart({...})`).
|
||||
*
|
||||
* Pair with the browser transport's `headStart: "/api/chat"`
|
||||
* option so the first message of a brand-new chat lands here
|
||||
* before the agent run boots.
|
||||
*/
|
||||
headStart<TTools extends Record<string, Tool>>(
|
||||
opts: HeadStartHandlerOptions<TTools>
|
||||
): (req: Request) => Promise<Response> {
|
||||
return async (req: Request) => {
|
||||
const session = await openHandoverSession({
|
||||
req,
|
||||
agentId: opts.agentId,
|
||||
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
|
||||
});
|
||||
|
||||
const helper: HeadStartChatHelper<TTools> = {
|
||||
toStreamTextOptions(spreadOpts) {
|
||||
return session.buildStreamTextOptions(spreadOpts) as any;
|
||||
},
|
||||
session: session.handle,
|
||||
};
|
||||
|
||||
const result = await opts.run({
|
||||
messages: session.uiMessages,
|
||||
signal: session.combinedSignal,
|
||||
chat: helper,
|
||||
});
|
||||
|
||||
return session.handle.handoverResponse(result);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Lower-level primitive for power users who want to call
|
||||
* `streamText` themselves outside the `run` callback shape — custom
|
||||
* transforms, non-AI-SDK code paths, or manual control over the
|
||||
* response. Same wiring `chat.headStart` builds on internally.
|
||||
*/
|
||||
openSession(opts: {
|
||||
req: Request;
|
||||
agentId: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
}): Promise<HeadStartSession> {
|
||||
return openHandoverSession(opts).then((s) => s.handle);
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap a Web Fetch handler — `(req: Request) => Promise<Response>` —
|
||||
* as a Node `http` listener — `(req: IncomingMessage, res: ServerResponse) => Promise<void>`.
|
||||
*
|
||||
* Use this to mount `chat.headStart` (or any other Web Fetch
|
||||
* handler) inside Node-only frameworks like Express, Fastify, Koa,
|
||||
* or raw `node:http`. Web-native frameworks (Next.js App Router,
|
||||
* Hono, SvelteKit, Remix, Workers, Bun, Deno, etc.) don't need
|
||||
* this — they pass `Request` objects directly.
|
||||
*
|
||||
* Streams the response body chunk-by-chunk to the Node response,
|
||||
* so the `chat.headStart` SSE chunks reach the browser as they
|
||||
* arrive (no buffering). Aborts the underlying handler if the
|
||||
* client closes the connection.
|
||||
*
|
||||
* Type-only import of `node:http` types — no runtime dep on `node:http`,
|
||||
* so this stays safe to bundle into edge / Workers builds (the
|
||||
* function just won't be called there).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import express from "express";
|
||||
* import { chat } from "@trigger.dev/sdk/chat-server";
|
||||
*
|
||||
* const handler = chat.headStart({
|
||||
* agentId: "my-chat",
|
||||
* run: async ({ chat: helper }) => streamText({ ... }),
|
||||
* });
|
||||
*
|
||||
* const app = express();
|
||||
* app.post("/api/chat", chat.toNodeListener(handler));
|
||||
* ```
|
||||
*/
|
||||
toNodeListener,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InternalSession = {
|
||||
uiMessages: UIMessage[];
|
||||
combinedSignal: AbortSignal;
|
||||
handle: HeadStartSession;
|
||||
buildStreamTextOptions(spreadOpts?: { tools?: Record<string, Tool> }): Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function openHandoverSession(opts: {
|
||||
req: Request;
|
||||
agentId: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
}): Promise<InternalSession> {
|
||||
const wirePayload = (await opts.req.json()) as ChatTaskWirePayload;
|
||||
const chatId = wirePayload.chatId;
|
||||
if (!chatId) {
|
||||
throw new Error("[chat.handover] request body missing `chatId`");
|
||||
}
|
||||
// Slim wire — head-start ships full history via `headStartMessages` (not
|
||||
// `message`/`messages`) because the route handler runs on the customer's
|
||||
// own HTTP endpoint and isn't subject to the 512 KiB `/in/append` cap.
|
||||
// The full UIMessage[] flows through `wirePayload` into the auto-trigger
|
||||
// `basePayload` below, where the agent run boot consumes it on first turn.
|
||||
const uiMessages = (wirePayload.headStartMessages ?? []) as UIMessage[];
|
||||
// `convertToModelMessages` is async — resolve once up front so the
|
||||
// synchronous `toStreamTextOptions` builder can hand back a fully
|
||||
// formed object. AI SDK's `streamText` validates `messages` as a
|
||||
// `ModelMessage[]` synchronously and rejects a Promise.
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
|
||||
const apiClient = resolveApiClient();
|
||||
const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
|
||||
|
||||
// Create the session and trigger the chat.agent's `handover-prepare`
|
||||
// run atomically. `createSession` is idempotent on `(env, externalId
|
||||
// = chatId)` and the auto-triggered run uses `triggerConfig.
|
||||
// basePayload` as the wire payload — so a single round-trip both
|
||||
// ensures the session exists and starts the agent booting with the
|
||||
// right trigger.
|
||||
//
|
||||
// Awaited intentionally: subsequent writes to `session.out` (the
|
||||
// tee from the customer's `streamText` to S2) need the session to
|
||||
// exist, and the handover signal at end-of-step-1 needs the agent
|
||||
// run to be there to consume it. The added latency (~one round trip
|
||||
// to the control plane) is bounded; the agent's compute boot still
|
||||
// overlaps with LLM TTFB.
|
||||
const created = await apiClient.createSession({
|
||||
type: "chat.agent",
|
||||
externalId: chatId,
|
||||
taskIdentifier: opts.agentId,
|
||||
triggerConfig: {
|
||||
basePayload: {
|
||||
...wirePayload,
|
||||
chatId,
|
||||
trigger: "handover-prepare",
|
||||
idleTimeoutInSeconds,
|
||||
},
|
||||
idleTimeoutInSeconds,
|
||||
},
|
||||
});
|
||||
const sessionPublicAccessToken = created.publicAccessToken;
|
||||
|
||||
// Combined abort signal: request lifecycle OR an internal timeout
|
||||
// mirroring the agent's idle wait so a hung handler doesn't sit
|
||||
// forever.
|
||||
const abortController = new AbortController();
|
||||
const requestAbort = (opts.req as Request & { signal?: AbortSignal }).signal;
|
||||
if (requestAbort) {
|
||||
if (requestAbort.aborted) abortController.abort();
|
||||
else requestAbort.addEventListener("abort", () => abortController.abort(), { once: true });
|
||||
}
|
||||
const idleTimer = setTimeout(
|
||||
() => abortController.abort(new Error("chat.handover: idle timeout")),
|
||||
idleTimeoutInSeconds * 1000
|
||||
);
|
||||
|
||||
const buildStreamTextOptions = (
|
||||
spreadOpts?: { tools?: Record<string, Tool> }
|
||||
): Record<string, unknown> => {
|
||||
// The customer spreads this object into their `streamText` call
|
||||
// and then adds `model`, `system`, etc. on top. We set the four
|
||||
// keys handover correctness depends on:
|
||||
//
|
||||
// - `messages`: the wire payload's UIMessages, converted
|
||||
// (Promise resolved upfront so the spread is synchronous)
|
||||
// - `tools`: customer's schema-only tool set
|
||||
// - `stopWhen`: `stepCountIs(1)` — step 1 only. Agent run picks
|
||||
// up tool execution and step 2+ after the handover signal.
|
||||
// - `abortSignal`: combined request-lifecycle + idle timeout
|
||||
//
|
||||
// The customer's `StreamTextResult` exposes `finishReason` and
|
||||
// `response.messages` directly, so we don't need to install an
|
||||
// `onStepFinish` capture hook — we read those off the result in
|
||||
// `handoverWhenDone`.
|
||||
return {
|
||||
messages: modelMessages,
|
||||
tools: spreadOpts?.tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
};
|
||||
|
||||
// Tee a UIMessage stream into session.out via S2 direct-write,
|
||||
// batched. `SessionStreamInstance` calls `initializeSessionStream`
|
||||
// once to fetch S2 credentials, then pipes via `StreamsWriterV2`'s
|
||||
// `BatchTransform` — one S2 append per ~200ms of chunks instead of
|
||||
// one HTTP round-trip per UIMessageChunk.
|
||||
let sessionWriter: SessionStreamInstance<UIMessageChunk> | null = null;
|
||||
const tee = (stream: ReadableStream<UIMessageChunk>): ReadableStream<UIMessageChunk> => {
|
||||
const [a, b] = stream.tee();
|
||||
sessionWriter = new SessionStreamInstance<UIMessageChunk>({
|
||||
apiClient,
|
||||
baseUrl: apiClient.baseUrl,
|
||||
sessionId: chatId, // Sessions are addressable by externalId (chatId).
|
||||
io: "out",
|
||||
source: b,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
return a;
|
||||
};
|
||||
/** Wait for the teed S2 writer to drain. Called before signaling handover. */
|
||||
const flushSessionWriter = async (): Promise<void> => {
|
||||
if (!sessionWriter) return;
|
||||
try {
|
||||
await sessionWriter.wait();
|
||||
} catch {
|
||||
// Drop write errors — the customer's response stream is the
|
||||
// source of truth for what the user sees. Durability/resume
|
||||
// best-effort.
|
||||
}
|
||||
};
|
||||
|
||||
const handover = async (args: {
|
||||
partialAssistantMessage: ModelMessage[];
|
||||
messageId?: string;
|
||||
isFinal: boolean;
|
||||
}) => {
|
||||
const chunk: ChatInputChunk = {
|
||||
kind: "handover",
|
||||
partialAssistantMessage: args.partialAssistantMessage,
|
||||
messageId: args.messageId,
|
||||
isFinal: args.isFinal,
|
||||
};
|
||||
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent only on dispatch error (handler aborted before producing a
|
||||
* `finishReason`). Normal pure-text and tool-call finishes go
|
||||
* through `handover()` with the appropriate `isFinal` flag.
|
||||
*/
|
||||
const handoverSkip = async () => {
|
||||
const chunk: ChatInputChunk = { kind: "handover-skip" };
|
||||
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
|
||||
};
|
||||
|
||||
// A stable assistant messageId for this turn. The customer's
|
||||
// `toUIMessageStream` is configured to emit its `start` chunk with
|
||||
// this id, the handover signal carries it to the agent, and the
|
||||
// agent's post-handover `toUIMessageStream` reuses it — so all
|
||||
// chunks (customer's step 1 + agent's step 2) merge into one
|
||||
// assistant message on the browser side.
|
||||
const turnMessageId = generateAssistantMessageId();
|
||||
|
||||
// Set by `handoverWhenDone` after it observes `result.finishReason`
|
||||
// and dispatches the handover decision. The stitched response stream
|
||||
// awaits this to know whether to close (skip) or pull more chunks
|
||||
// from session.out (handover).
|
||||
type HandoverDecision = { kind: "handover" | "handover-skip" };
|
||||
let resolveDecision!: (decision: HandoverDecision) => void;
|
||||
const decisionPromise = new Promise<HandoverDecision>((resolve) => {
|
||||
resolveDecision = resolve;
|
||||
});
|
||||
|
||||
const handoverWhenDone = async (result: StreamTextResult<any, any>) => {
|
||||
// Owns idle-timer cleanup via the finally below, so both the
|
||||
// sugar (`handoverResponse`) and the escape-hatch
|
||||
// (`chat.openSession()` → `handle.handoverWhenDone(...)`) clean up
|
||||
// the timer the same way.
|
||||
try {
|
||||
// `result.finishReason` is a Promise<FinishReason> on the AI SDK
|
||||
// result. Wait for the stream to settle, then dispatch.
|
||||
const finishReason = await result.finishReason;
|
||||
|
||||
// Drain the S2 tee so any in-flight handler writes (last
|
||||
// `tool-input-available` parts, the synthetic `finish-step` for
|
||||
// pure-text) are visible before the agent reads from session.out
|
||||
// / session.in. Cooperative ordering — agent doesn't read past
|
||||
// these unless we've finished writing them.
|
||||
await flushSessionWriter();
|
||||
|
||||
const responseMessages = (await result.response).messages as ModelMessage[];
|
||||
|
||||
if (finishReason === "tool-calls") {
|
||||
// Reshape pending tool-calls into AI SDK's tool-approval round
|
||||
// so the agent's `streamText` resumes by executing them
|
||||
// before the step-2 LLM call.
|
||||
const reshaped = reshapeForHandoverResume(responseMessages);
|
||||
await handover({
|
||||
partialAssistantMessage: reshaped,
|
||||
messageId: turnMessageId,
|
||||
isFinal: false,
|
||||
});
|
||||
} else {
|
||||
// Pure-text (or any non-tool-calls) finish — customer's step 1
|
||||
// IS the final response. The agent runs the turn-loop hooks
|
||||
// (`onChatStart`, `onTurnStart`, `onTurnComplete`, etc.) using
|
||||
// this partial as the response, but skips the LLM call. That
|
||||
// way persistence (`onTurnComplete` writing to DB), self-
|
||||
// review, and any post-turn work all fire normally.
|
||||
await handover({
|
||||
partialAssistantMessage: responseMessages,
|
||||
messageId: turnMessageId,
|
||||
isFinal: true,
|
||||
});
|
||||
}
|
||||
resolveDecision({ kind: "handover" });
|
||||
} catch (err) {
|
||||
// Dispatch failed before we could send the handover signal.
|
||||
// Tell the agent to exit clean (no hooks fire) and close the
|
||||
// response stream so it doesn't hang waiting for agent chunks.
|
||||
resolveDecision({ kind: "handover-skip" });
|
||||
try {
|
||||
await handoverSkip();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a single ReadableStream that:
|
||||
* 1. Forwards the customer's `streamText` chunks (step 1) directly
|
||||
* to the response — same low-latency path as before.
|
||||
* 2. After step 1 ends and the dispatch decision lands:
|
||||
* - `handover-skip`: closes the response immediately. The agent
|
||||
* run exits without writing more chunks.
|
||||
* - `handover`: subscribes to `session.out` from the sequence
|
||||
* ID where the customer's tee left off, forwarding the agent
|
||||
* run's chunks (tool-output-available, step 2 LLM text,
|
||||
* `finish-step`, etc.) until `trigger:turn-complete`.
|
||||
*
|
||||
* The browser sees one continuous SSE response per first turn, just
|
||||
* like a normal `streamText` would produce.
|
||||
*/
|
||||
const stitchHandoverStream = (
|
||||
customerBranch: ReadableStream<UIMessageChunk>
|
||||
): ReadableStream<UIMessageChunk> => {
|
||||
return new ReadableStream<UIMessageChunk>({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Phase 1: forward customer's chunks.
|
||||
const reader = customerBranch.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Phase 2a: wait for handoverWhenDone to decide.
|
||||
const decision = await decisionPromise;
|
||||
if (decision.kind === "handover-skip") {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2b: agent is taking over. Resume from session.out
|
||||
// starting AFTER the customer tee's last write, so we don't
|
||||
// re-emit chunks the browser already saw.
|
||||
const writeResult = sessionWriter
|
||||
? await sessionWriter.wait().catch(() => undefined)
|
||||
: undefined;
|
||||
const customerLastEventId = writeResult?.lastEventId;
|
||||
|
||||
// Capture the latest S2 event id seen on session.out via
|
||||
// `onPart`. After the stream closes we emit it to the
|
||||
// browser as a `trigger:session-state` control chunk so the
|
||||
// transport can hydrate `state.lastEventId` for turn 2's
|
||||
// subscribe — without it, turn 2 reads session.out from the
|
||||
// start and replays turn 1 to the user.
|
||||
let latestEventId: string | undefined;
|
||||
const agentStream = await apiClient.subscribeToSessionStream<UIMessageChunk>(
|
||||
chatId,
|
||||
"out",
|
||||
{
|
||||
...(customerLastEventId != null
|
||||
? { lastEventId: customerLastEventId }
|
||||
: {}),
|
||||
signal: abortController.signal,
|
||||
onPart: (part) => {
|
||||
if (part.id) latestEventId = part.id;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
for await (const chunk of agentStream) {
|
||||
controller.enqueue(chunk);
|
||||
// The agent's run-loop emits `trigger:turn-complete` when
|
||||
// the turn finishes. That's our cue to close — anything
|
||||
// after is the next turn (which goes via the direct
|
||||
// `session.in`/`session.out` path, not this endpoint).
|
||||
if (
|
||||
chunk &&
|
||||
typeof chunk === "object" &&
|
||||
(chunk as { type?: unknown }).type === "trigger:turn-complete"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final control chunk: hand the browser transport the
|
||||
// `lastEventId` it should use for the next turn's
|
||||
// session.out subscribe. Filtered out before reaching the
|
||||
// AI SDK on the browser side.
|
||||
if (latestEventId != null) {
|
||||
controller.enqueue({
|
||||
type: "trigger:session-state",
|
||||
lastEventId: latestEventId,
|
||||
} as unknown as UIMessageChunk);
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Browser closed the connection. Trigger the abort so any
|
||||
// pending session.out subscription stops too.
|
||||
abortController.abort();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handoverResponse = (result: StreamTextResult<any, any>): Response => {
|
||||
// `generateMessageId` makes the customer's `start` chunk carry
|
||||
// `turnMessageId`, so the browser-side AI SDK keys the assistant
|
||||
// message by it. The agent's post-handover stream emits chunks
|
||||
// with the same id (passed via the handover signal) — both sides
|
||||
// merge into one message on the browser.
|
||||
const teed = tee(
|
||||
result.toUIMessageStream({
|
||||
generateMessageId: () => turnMessageId,
|
||||
})
|
||||
);
|
||||
// `handoverWhenDone` re-throws on dispatch failure for visibility,
|
||||
// but the recovery (resolveDecision + handoverSkip) has already run
|
||||
// by then and `stitchHandoverStream` closes the response cleanly via
|
||||
// `decisionPromise`. The user-facing path is fine; we only suppress
|
||||
// the unhandled-rejection so processes started with
|
||||
// `--unhandled-rejections=throw` don't crash on what is effectively
|
||||
// a logged failure with no further action to take.
|
||||
// (Idle-timer cleanup lives inside `handoverWhenDone` itself.)
|
||||
void handoverWhenDone(result).catch(() => {});
|
||||
|
||||
const stitched = stitchHandoverStream(teed);
|
||||
|
||||
// Encode UIMessageChunks as SSE for the AI SDK transport on the
|
||||
// browser. AI SDK's `toUIMessageStreamResponse()` does this same
|
||||
// thing internally; replicate the format here so we don't have
|
||||
// to bridge through the SDK's response helper.
|
||||
const encoder = new TextEncoder();
|
||||
const sseStream = stitched.pipeThrough(
|
||||
new TransformStream<UIMessageChunk, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(sseStream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
// Browser transport reads these to hydrate session state
|
||||
// for subsequent (non-handover) turns. Once the browser has
|
||||
// the PAT it talks directly to `session.in` / `session.out`
|
||||
// without going back through the handler.
|
||||
"X-Trigger-Chat-Id": chatId,
|
||||
"X-Trigger-Chat-Access-Token": sessionPublicAccessToken,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handle: HeadStartSession = {
|
||||
chatId,
|
||||
tee,
|
||||
handoverWhenDone,
|
||||
handoverResponse,
|
||||
handover,
|
||||
handoverSkip,
|
||||
};
|
||||
|
||||
return {
|
||||
uiMessages,
|
||||
combinedSignal: abortController.signal,
|
||||
handle,
|
||||
buildStreamTextOptions,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApiClient(): ApiClient {
|
||||
// Reuse the SDK's standard apiClientManager so customers configure
|
||||
// base URL + secret key the same way as for `tasks.trigger(...)`.
|
||||
const client = apiClientManager.clientOrThrow();
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node `http` adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Minimal Node http types we use. Avoids a `node:http` type import so the
|
||||
// file stays lint-clean on non-Node TS projects (the docs example handlers
|
||||
// might typecheck under workers / deno configs that lack `node:` types).
|
||||
interface NodeIncomingHeaders {
|
||||
[k: string]: string | string[] | undefined;
|
||||
}
|
||||
interface NodeIncomingMessage extends AsyncIterable<unknown> {
|
||||
readonly url?: string;
|
||||
readonly method?: string;
|
||||
readonly headers: NodeIncomingHeaders;
|
||||
on(event: "error", listener: (err: Error) => void): unknown;
|
||||
}
|
||||
interface NodeServerResponse {
|
||||
statusCode: number;
|
||||
headersSent: boolean;
|
||||
setHeader(name: string, value: string | number | readonly string[]): unknown;
|
||||
write(chunk: Uint8Array | string): boolean;
|
||||
end(chunk?: Uint8Array | string): unknown;
|
||||
on(event: "close" | "error", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
/** @internal — exposed via `chat.toNodeListener`. */
|
||||
function toNodeListener(
|
||||
webHandler: (req: Request) => Promise<Response>
|
||||
): (req: NodeIncomingMessage, res: NodeServerResponse) => Promise<void> {
|
||||
return async function nodeListener(req, res) {
|
||||
const abort = new AbortController();
|
||||
res.on("close", () => abort.abort());
|
||||
|
||||
try {
|
||||
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
||||
const method = req.method ?? "GET";
|
||||
const hasBody = method !== "GET" && method !== "HEAD";
|
||||
|
||||
// Read full body upfront. Chat wire payloads are small (sub-KB
|
||||
// typically) so accumulating avoids the duplex-stream ceremony
|
||||
// some Node versions need for streaming request bodies into
|
||||
// a Web Request.
|
||||
let body: ArrayBuffer | undefined;
|
||||
if (hasBody) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of req as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (chunks.length > 0) {
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.length;
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
merged.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
body = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten Node header values: arrays → comma-joined (per RFC 7230 §3.2.2).
|
||||
const webHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(req.headers)) {
|
||||
if (value == null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) webHeaders.append(name, v);
|
||||
} else {
|
||||
webHeaders.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const webReq = new Request(url, {
|
||||
method,
|
||||
headers: webHeaders,
|
||||
body,
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
const webRes = await webHandler(webReq);
|
||||
|
||||
res.statusCode = webRes.status;
|
||||
// `Headers.forEach` exposes the value comma-joined for multi-valued
|
||||
// headers, which `setHeader` accepts. Set-Cookie is handled separately
|
||||
// via `getSetCookie()` to preserve multiple values.
|
||||
webRes.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === "set-cookie") return;
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
const setCookies =
|
||||
typeof (webRes.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie === "function"
|
||||
? (webRes.headers as Headers & { getSetCookie: () => string[] }).getSetCookie()
|
||||
: [];
|
||||
if (setCookies.length > 0) {
|
||||
res.setHeader("set-cookie", setCookies);
|
||||
}
|
||||
|
||||
if (!webRes.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pipe the Web Response body to the Node response. On client
|
||||
// disconnect (`abort.signal`), cancel the reader so a pending
|
||||
// `read()` rejects and we exit the loop instead of blocking on
|
||||
// a stream that will never produce more chunks.
|
||||
const reader = webRes.body.getReader();
|
||||
const onAbort = () => {
|
||||
reader.cancel(abort.signal.reason).catch(() => {});
|
||||
};
|
||||
if (abort.signal.aborted) onAbort();
|
||||
else abort.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(value);
|
||||
}
|
||||
} catch {
|
||||
// Reader was cancelled (client disconnect). Silently end.
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
res.end();
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("content-type", "text/plain; charset=utf-8");
|
||||
res.end(err instanceof Error ? err.message : "Internal error");
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reshape a step-1 partial so the agent's `streamText` resumes by
|
||||
* executing pending tool-calls before the next LLM call.
|
||||
*
|
||||
* When the customer's handler runs `streamText` with schema-only tools
|
||||
* (no `execute` fns) and `stopWhen: stepCountIs(1)`, the LLM emits
|
||||
* tool-calls but AI SDK can't execute them — the partial we ship is
|
||||
* `[{ assistant: text + tool-call }]`. Splicing that as-is onto the
|
||||
* agent's accumulator and calling `streamText` throws
|
||||
* `MissingToolResultsError` synchronously inside
|
||||
* `convertToLanguageModelPrompt`.
|
||||
*
|
||||
* AI SDK's documented escape hatch for "external party decides what
|
||||
* to do with a tool-call, then SDK executes" is the tool-approval
|
||||
* round. By appending a `tool-approval-request` part to the assistant
|
||||
* message and a trailing `tool` message with a matching
|
||||
* `tool-approval-response { approved: true }`, AI SDK:
|
||||
* 1. Suppresses `MissingToolResultsError` for approved tool-calls
|
||||
* (`convert-to-language-model-prompt.ts:135-144`).
|
||||
* 2. Hits its initial-tool-execution branch
|
||||
* (`stream-text.ts:1342-1486`) on the next `streamText` call,
|
||||
* runs the agent-side `execute` fns, and synthesizes
|
||||
* `tool-result` parts before the step-2 LLM call.
|
||||
*
|
||||
* If the customer's tools already had `execute` fns (rare for the
|
||||
* handover use case but valid), the partial already contains a
|
||||
* `tool-result` per tool-call — we leave those alone and only inject
|
||||
* approvals for genuinely-pending calls.
|
||||
*
|
||||
* `collectToolApprovals` only scans the LAST message
|
||||
* (`collect-tool-approvals.ts:30-37`), so the synthesized tool message
|
||||
* must end up at the tail of the partial. The agent's run-loop
|
||||
* splices the partial onto the end of the accumulator, which keeps
|
||||
* this invariant.
|
||||
*/
|
||||
function reshapeForHandoverResume(responseMessages: ModelMessage[]): ModelMessage[] {
|
||||
// First pass: gather the set of tool-call IDs that already have a
|
||||
// matching tool-result. Those are "complete" — leave them alone.
|
||||
const completedToolCallIds = new Set<string>();
|
||||
for (const message of responseMessages) {
|
||||
if (message.role !== "tool" || typeof message.content === "string") continue;
|
||||
for (const part of message.content as Array<{ type: string; toolCallId?: string }>) {
|
||||
if (part.type === "tool-result" && part.toolCallId) {
|
||||
completedToolCallIds.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: clone the messages, appending a tool-approval-request
|
||||
// alongside each pending tool-call. Collect the matching responses.
|
||||
const approvalResponses: Array<{
|
||||
type: "tool-approval-response";
|
||||
approvalId: string;
|
||||
approved: true;
|
||||
}> = [];
|
||||
let approvalCounter = 0;
|
||||
|
||||
const reshaped: ModelMessage[] = responseMessages.map((message) => {
|
||||
if (message.role !== "assistant" || typeof message.content === "string") {
|
||||
return message;
|
||||
}
|
||||
const newContent: typeof message.content = [...message.content];
|
||||
for (const part of message.content as Array<{
|
||||
type: string;
|
||||
toolCallId?: string;
|
||||
}>) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
part.toolCallId &&
|
||||
!completedToolCallIds.has(part.toolCallId)
|
||||
) {
|
||||
const approvalId = `handover-approval-${++approvalCounter}`;
|
||||
newContent.push({
|
||||
type: "tool-approval-request",
|
||||
approvalId,
|
||||
toolCallId: part.toolCallId,
|
||||
} as never);
|
||||
approvalResponses.push({
|
||||
type: "tool-approval-response",
|
||||
approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...message, content: newContent } as ModelMessage;
|
||||
});
|
||||
|
||||
if (approvalResponses.length > 0) {
|
||||
reshaped.push({
|
||||
role: "tool",
|
||||
content: approvalResponses as never,
|
||||
} as ModelMessage);
|
||||
}
|
||||
|
||||
return reshaped;
|
||||
}
|
||||
@@ -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,56 @@
|
||||
import type {
|
||||
ApiRequestOptions,
|
||||
RetrieveCurrentDeploymentResponseBody,
|
||||
ApiDeploymentListOptions,
|
||||
ApiDeploymentListResponseItem,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
apiClientManager,
|
||||
CursorPagePromise,
|
||||
isRequestOptions,
|
||||
mergeRequestOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
|
||||
export type { RetrieveCurrentDeploymentResponseBody, ApiDeploymentListResponseItem };
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -17,14 +17,15 @@ 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 } from "@trigger.dev/core/v3";
|
||||
import type { ApiClientConfiguration, TaskRunContext } from "@trigger.dev/core/v3";
|
||||
|
||||
export type { ApiClientConfiguration };
|
||||
export type { ApiClientConfiguration, TaskRunContext };
|
||||
|
||||
export {
|
||||
ApiError,
|
||||
@@ -39,6 +40,8 @@ export {
|
||||
AbortTaskRunError,
|
||||
OutOfMemoryError,
|
||||
CompleteTaskWithOutput,
|
||||
ChatChunkTooLargeError,
|
||||
isChatChunkTooLargeError,
|
||||
logger,
|
||||
type LogLevel,
|
||||
} from "@trigger.dev/core/v3";
|
||||
@@ -54,9 +57,15 @@ export {
|
||||
type AnyRetrieveRunResult,
|
||||
} 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 * as prompts from "./prompts.js";
|
||||
export * as skills from "./skills.js";
|
||||
|
||||
@@ -358,6 +358,14 @@ export type SubscribeToRunOptions = {
|
||||
* ```
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -403,6 +411,7 @@ function subscribeToRun<TRunId extends AnyRunHandle | AnyTask | string>(
|
||||
closeOnComplete:
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
|
||||
skipColumns: options?.skipColumns,
|
||||
signal: options?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import type {
|
||||
ApiPromise,
|
||||
ApiRequestOptions,
|
||||
AsyncIterableStream,
|
||||
CloseSessionRequestBody,
|
||||
CreatedSessionResponseBody,
|
||||
CreateSessionRequestBody,
|
||||
InputStreamOnceOptions,
|
||||
InputStreamOnceResult,
|
||||
InputStreamWaitOptions,
|
||||
InputStreamWaitWithIdleTimeoutOptions,
|
||||
ListSessionsOptions,
|
||||
ListedSessionItem,
|
||||
PipeStreamOptions,
|
||||
PipeStreamResult,
|
||||
RetrieveSessionResponseBody,
|
||||
UpdateSessionRequestBody,
|
||||
WriterStreamOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
CursorPagePromise,
|
||||
InputStreamOncePromise,
|
||||
ManualWaitpointPromise,
|
||||
SemanticInternalAttributes,
|
||||
SessionStreamInstance,
|
||||
WaitpointTimeoutError,
|
||||
accessoryAttributes,
|
||||
apiClientManager,
|
||||
ensureReadableStream,
|
||||
mergeRequestOptions,
|
||||
runtime,
|
||||
sessionStreams,
|
||||
taskContext,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
import { tracer } from "./tracer.js";
|
||||
|
||||
export type {
|
||||
CreatedSessionResponseBody,
|
||||
CreateSessionRequestBody,
|
||||
CloseSessionRequestBody,
|
||||
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 durable, task-bound, bidirectional I/O
|
||||
* primitive. 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 {
|
||||
constructor(public readonly sessionId: string) {}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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();
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any?.([options.signal, abortController.signal]) ?? abortController.signal
|
||||
: abortController.signal;
|
||||
|
||||
try {
|
||||
const instance = new SessionStreamInstance<T>({
|
||||
apiClient,
|
||||
baseUrl: apiClientManager.baseURL ?? "",
|
||||
sessionId: this.sessionId,
|
||||
io: "out",
|
||||
source: readableStreamSource,
|
||||
signal: combinedSignal,
|
||||
requestOptions: options?.requestOptions,
|
||||
});
|
||||
|
||||
instance.wait().finally(() => {
|
||||
span.end();
|
||||
});
|
||||
|
||||
return {
|
||||
stream: instance.stream,
|
||||
waitUntilComplete: async () => {
|
||||
return instance.wait();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Handlers are flushed with any buffered records on attach and cleaned
|
||||
* up automatically when the task run completes. Returns `{ off }` to
|
||||
* unsubscribe early.
|
||||
*/
|
||||
on<T = unknown>(handler: (data: T) => void | Promise<void>): { off: () => void } {
|
||||
return sessionStreams.on(
|
||||
this.sessionId,
|
||||
"in",
|
||||
handler as (data: unknown) => void | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the seq counter so the SSE tail doesn't replay the
|
||||
// record that was consumed via the waitpoint.
|
||||
const prevSeq = sessionStreams.lastSeqNum(this.sessionId, "in");
|
||||
const nextSeq = (prevSeq ?? -1) + 1;
|
||||
sessionStreams.setLastSeqNum(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 }> {
|
||||
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 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,211 @@
|
||||
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,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,738 @@
|
||||
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,
|
||||
__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";
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
__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. Cleared in the same `finally` block
|
||||
// as the other test overrides.
|
||||
__setReplaySessionOutTailImplForTests(async () => {
|
||||
if (seededReplayChunks.length === 0) return [];
|
||||
return (await reduceChunksToMessages(seededReplayChunks)) 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 } : {}),
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 ?? [];
|
||||
},
|
||||
|
||||
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,268 @@
|
||||
import type {
|
||||
AsyncIterableStream,
|
||||
PipeStreamResult,
|
||||
StreamWriteResult,
|
||||
WriterStreamOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { ensureReadableStream, ManualWaitpointPromise } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
SessionHandle,
|
||||
SessionInputChannel,
|
||||
SessionOutputChannel,
|
||||
SessionPipeStreamOptions,
|
||||
SessionSubscribeOptions,
|
||||
} 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,279 @@
|
||||
// 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",
|
||||
lastOutTimestamp: 2_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
|
||||
* client whose `getPayloadUrl` / `createUploadPayloadUrl` resolve with the
|
||||
* presigned URLs the test wants. Returns spies for assertion.
|
||||
*/
|
||||
function stubApiClient(opts: {
|
||||
getPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
|
||||
createUploadPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
|
||||
}) {
|
||||
const getPayloadUrl = vi.fn(
|
||||
opts.getPayloadUrl ?? (async (_filename: string) => ({ presignedUrl: "https://example.invalid/get" }))
|
||||
);
|
||||
const createUploadPayloadUrl = vi.fn(
|
||||
opts.createUploadPayloadUrl ??
|
||||
(async (_filename: string) => ({ presignedUrl: "https://example.invalid/put" }))
|
||||
);
|
||||
const fakeClient = {
|
||||
getPayloadUrl,
|
||||
createUploadPayloadUrl,
|
||||
};
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(
|
||||
fakeClient as never
|
||||
);
|
||||
return { getPayloadUrl, createUploadPayloadUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { getPayloadUrl } = 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(getPayloadUrl).toHaveBeenCalledWith("sessions/session-1/snapshot.json");
|
||||
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({
|
||||
getPayloadUrl: 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 { createUploadPayloadUrl } = stubApiClient({});
|
||||
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
|
||||
|
||||
const snapshot = buildSnapshot(3);
|
||||
await writeChatSnapshot("session-2", snapshot);
|
||||
|
||||
expect(createUploadPayloadUrl).toHaveBeenCalledWith("sessions/session-2/snapshot.json");
|
||||
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({
|
||||
createUploadPayloadUrl: 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("uses the same `snapshotFilename(sessionId)` convention as the read path", async () => {
|
||||
// Round-trip check: read and write target the same key for a given
|
||||
// sessionId. The runtime relies on this to make read-after-write
|
||||
// coherent on subsequent boots.
|
||||
const { getPayloadUrl } = stubApiClient({
|
||||
getPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
|
||||
});
|
||||
stubFetch(async () => new Response(null, { status: 404 }));
|
||||
|
||||
// Trigger a read.
|
||||
await readChatSnapshot("round-trip-session");
|
||||
const [readKey] = getPayloadUrl.mock.calls[0]!;
|
||||
|
||||
// Trigger a write to the same session.
|
||||
const { createUploadPayloadUrl } = stubApiClient({
|
||||
createUploadPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
|
||||
});
|
||||
stubFetch(async () => new Response(null, { status: 200 }));
|
||||
await writeChatSnapshot("round-trip-session", buildSnapshot());
|
||||
const [writeKey] = createUploadPayloadUrl.mock.calls[0]!;
|
||||
|
||||
expect(readKey).toBe(writeKey);
|
||||
expect(readKey).toBe("sessions/round-trip-session/snapshot.json");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
// 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("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,158 @@
|
||||
// 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,307 @@
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `textTurn` but omits the trailing `finish` chunk — simulates a
|
||||
* crashed turn whose stream ended mid-message. The runtime's reducer
|
||||
* should run `cleanupAbortedParts` on the resulting trailing message.
|
||||
*/
|
||||
function partialTurn(id: string, text: string): UIMessageChunk[] {
|
||||
return [
|
||||
{ type: "start", messageId: id, messageMetadata: { role: "assistant" } } as UIMessageChunk,
|
||||
{ type: "text-start", id: `${id}.t1` } as UIMessageChunk,
|
||||
{ type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk,
|
||||
// No text-end, no finish.
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow().readSessionStreamRecords` so the
|
||||
* helper sees a `{ records: StreamRecord[] }` response. Each StreamRecord
|
||||
* is `{ data: string, id, seqNum }` — `data` is the JSON-encoded chunk
|
||||
* body the runtime then `JSON.parse`s.
|
||||
*
|
||||
* Pass either a `UIMessageChunk` (will be JSON.stringify'd) or a raw
|
||||
* string (used as `data` directly — for tests that need pre-stringified
|
||||
* or deliberately-malformed bodies).
|
||||
*
|
||||
* Captures the `afterEventId` argument for resume-from-cursor assertions.
|
||||
*/
|
||||
function stubReadRecordsWithChunks(chunks: unknown[]) {
|
||||
const records = chunks.map((chunk, i) => ({
|
||||
data: typeof chunk === "string" ? chunk : JSON.stringify(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("JSON-decodes each record.data (every record arrives pre-serialized)", async () => {
|
||||
// The records endpoint hands each chunk back as a JSON string in
|
||||
// `record.data` — the agent JSON.parses it client-side so the
|
||||
// server's hot path doesn't pay the parse cost. Verify a normal
|
||||
// turn round-trips through JSON encode→decode.
|
||||
const stringChunks = textTurn("a-1", "from-string").map((c) => JSON.stringify(c));
|
||||
stubReadRecordsWithChunks(stringChunks);
|
||||
|
||||
const result = await replaySessionOutTail("string-chunks");
|
||||
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("from-string");
|
||||
});
|
||||
|
||||
it("skips records whose data is unparseable JSON", async () => {
|
||||
// The replay helper wraps the per-record JSON.parse in try/catch so
|
||||
// a single malformed record can't sink the rest of the replay. The
|
||||
// server should never serve a malformed `data`, but the defensive
|
||||
// catch lets a poisoned record skip cleanly.
|
||||
stubReadRecordsWithChunks([
|
||||
"not-json-{[",
|
||||
...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 decoded data is not an object", async () => {
|
||||
// After JSON.parse, the helper requires `chunk` to be a non-null
|
||||
// object with a string `type` field. Records that decode to
|
||||
// primitives (number, string, etc.) are dropped silently.
|
||||
stubReadRecordsWithChunks([
|
||||
JSON.stringify(42),
|
||||
JSON.stringify(null),
|
||||
JSON.stringify("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,249 @@
|
||||
// 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";
|
||||
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Generated
+1395
-71
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ minimumReleaseAgeExclude:
|
||||
- "next"
|
||||
- "@next/*"
|
||||
- "agentcrumbs"
|
||||
- "secure-exec"
|
||||
- "@secure-exec/*"
|
||||
preferOffline: true
|
||||
|
||||
linkWorkspacePackages: false
|
||||
|
||||
Reference in New Issue
Block a user