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:
Eric Allam
2026-05-10 22:26:59 +01:00
parent 979655c281
commit 16720a5e62
72 changed files with 23901 additions and 280 deletions
+16
View File
@@ -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.
+33
View File
@@ -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.
+21
View File
@@ -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.
+30
View File
@@ -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`.
+34
View File
@@ -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 Fetchonly 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 });
}
};
```
+6
View File
@@ -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;
};
};
// ---------------------------------------------------------------------------
@@ -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>
+3 -1
View File
@@ -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");
});
});
+315
View File
@@ -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
View File
@@ -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",
+16 -1
View File
@@ -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",
+172
View File
@@ -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
View File
@@ -1 +1,2 @@
export * from "./internal/additionalFiles.js";
export * from "./internal/copyFiles.js";
+13 -83
View File
@@ -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);
}
}
}
+99
View File
@@ -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 });
}
+15
View File
@@ -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;
}
+20 -1
View File
@@ -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();
});
});
+20
View File
@@ -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) => ({
+9
View File
@@ -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();
}
}
+226
View File
@@ -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();
});
});
});
+74
View File
@@ -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"]);
});
});
+85 -4
View File
@@ -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 });
});
});
}
+200
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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 = {
+797
View File
@@ -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);
}
+457
View File
@@ -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");
});
});
+915
View File
@@ -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);
}
+11 -2
View File
@@ -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";
+9
View File
@@ -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,
});
}
+751
View File
@@ -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;
}
+211
View File
@@ -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.`
);
},
};
}
+9
View File
@@ -0,0 +1,9 @@
export { defineSkill as define } from "./skill.js";
export type {
AnySkillHandle,
ResolvedSkill,
SkillFrontmatter,
SkillHandle,
SkillIdentifier,
SkillOptions,
} from "./skill.js";
+23
View File
@@ -0,0 +1,23 @@
// Importing this module installs an in-memory resource catalog so that
// chat.agent() calls (which run at import time) register their task
// functions where the test harness can find them.
//
// Users should import `@trigger.dev/sdk/ai/test` BEFORE their agent
// modules so the registration side-effect runs first.
import "./setup-catalog.js";
export {
mockChatAgent,
type MockChatAgentOptions,
type MockChatAgentHarness,
type MockChatAgentTurn,
} from "./mock-chat-agent.js";
// Re-export the lower-level task context harness so consumers can build
// their own test helpers without adding a separate `@trigger.dev/core`
// dependency to their reference projects.
export {
runInMockTaskContext,
type MockTaskContextDrivers,
type MockTaskContextOptions,
} from "@trigger.dev/core/v3/test";
@@ -0,0 +1,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();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { defineSkill, parseFrontmatter } from "../src/v3/skill.js";
describe("parseFrontmatter", () => {
it("parses name + description", () => {
const { frontmatter, body } = parseFrontmatter(
`---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n`
);
expect(frontmatter.name).toBe("pdf-processing");
expect(frontmatter.description).toBe("Extract text from PDFs.");
expect(body).toBe("# Body\n\nhello\n");
});
it("strips surrounding quotes", () => {
const { frontmatter } = parseFrontmatter(
`---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n`
);
expect(frontmatter.name).toBe("quoted-name");
expect(frontmatter.description).toBe("single quoted");
});
it("throws on missing frontmatter block", () => {
expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/);
});
it("throws on missing required name", () => {
expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow(
/missing required `name`/
);
});
it("throws on missing required description", () => {
expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow(
/missing required `description`/
);
});
});
describe("defineSkill.local()", () => {
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-")));
process.chdir(workdir);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => {
const skillDir = path.join(workdir, ".trigger", "skills", "pdf");
await mkdir(skillDir, { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n`
);
const skill = defineSkill({ id: "pdf", path: "./skills/pdf" });
const resolved = await skill.local();
expect(resolved.id).toBe("pdf");
expect(resolved.version).toBe("local");
expect(resolved.labels).toEqual([]);
expect(resolved.frontmatter.name).toBe("pdf");
expect(resolved.frontmatter.description).toBe("Extract PDF text.");
expect(resolved.body).toContain("# PDF skill");
expect(resolved.body).toContain("Use scripts/extract.py");
expect(resolved.path).toBe(skillDir);
});
it("throws a useful error when SKILL.md is missing", async () => {
const skill = defineSkill({ id: "missing", path: "./skills/missing" });
await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/);
});
it("resolve() throws with a helpful Phase 1 message", async () => {
const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" });
await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s);
});
});
@@ -0,0 +1,221 @@
// Import the test harness FIRST so the resource catalog is installed
import { mockChatAgent } from "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { MockLanguageModelV3 } from "ai/test";
import { simulateReadableStream, streamText } from "ai";
import { buildSkillTools, chat } from "../src/v3/ai.js";
import { defineSkill } from "../src/v3/skill.js";
function userMessage(text: string, id?: string) {
return {
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-")));
process.chdir(workdir);
// Bundled skill layout
const skillDir = path.join(workdir, ".trigger", "skills", "demo");
await mkdir(path.join(skillDir, "scripts"), { recursive: true });
await mkdir(path.join(skillDir, "references"), { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n`
);
const scriptPath = path.join(skillDir, "scripts", "hello.sh");
await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`);
await chmod(scriptPath, 0o755);
await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n");
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
describe("chat.skills runtime integration", () => {
it("injects skills preamble into the system prompt", async () => {
let capturedSystem: string | undefined;
const model = new MockLanguageModelV3({
doStream: async (opts) => {
const system = opts.prompt.find((m) => m.role === "system");
capturedSystem = system ? JSON.stringify(system.content) : undefined;
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.system-prompt",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t1" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedSystem).toContain("Available skills");
expect(capturedSystem).toContain("demo: Demo skill for tests");
} finally {
await harness.close();
}
});
it("auto-wires loadSkill / readFile / bash tools", async () => {
let capturedToolNames: string[] = [];
const model = new MockLanguageModelV3({
doStream: async (opts) => {
capturedToolNames = (opts.tools ?? []).map((t) => t.name);
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.auto-tools",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t2" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"]));
} finally {
await harness.close();
}
});
});
describe("buildSkillTools — direct execute", () => {
it("loadSkill returns body + path for a known skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const resolved = await skill.local();
const tools = buildSkillTools([resolved]);
const out = await (tools.loadSkill as any).execute({ name: "demo" });
expect(out.name).toBe("demo");
expect(out.body).toContain("# Demo");
expect(out.path).toBe(resolved.path);
});
it("loadSkill returns an error for an unknown skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.loadSkill as any).execute({ name: "missing" });
expect(out.error).toContain('Skill "missing" not found');
});
it("readFile reads a bundled reference", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "references/notes.txt",
});
expect(out.content).toBe("Reference note.\n");
});
it("readFile rejects path traversal", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "../../../../etc/passwd",
});
expect(out.error).toMatch(/escapes the skill directory/);
});
it("readFile rejects absolute paths", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "/etc/passwd",
});
expect(out.error).toMatch(/must be relative/);
});
it("bash runs a bundled script and captures stdout", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "bash scripts/hello.sh world",
});
expect(out.exitCode).toBe(0);
expect(out.stdout).toContain("hi from world");
});
it("bash reports non-zero exit code", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "exit 7",
});
expect(out.exitCode).toBe(7);
});
});
@@ -0,0 +1,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
+1395 -71
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,8 @@ minimumReleaseAgeExclude:
- "next"
- "@next/*"
- "agentcrumbs"
- "secure-exec"
- "@secure-exec/*"
preferOffline: true
linkWorkspacePackages: false