@trigger.dev/sdk 4.5.0-rc.5 → 4.5.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/dist/commonjs/v3/ai.d.ts +178 -5
  2. package/dist/commonjs/v3/ai.js +603 -119
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat-client.js +3 -0
  5. package/dist/commonjs/v3/chat-client.js.map +1 -1
  6. package/dist/commonjs/v3/chat-react.js +10 -7
  7. package/dist/commonjs/v3/chat-react.js.map +1 -1
  8. package/dist/commonjs/v3/chat-server.d.ts +8 -0
  9. package/dist/commonjs/v3/chat-server.js +32 -10
  10. package/dist/commonjs/v3/chat-server.js.map +1 -1
  11. package/dist/commonjs/v3/chat-server.test.js +51 -0
  12. package/dist/commonjs/v3/chat-server.test.js.map +1 -1
  13. package/dist/commonjs/v3/chat.js +34 -6
  14. package/dist/commonjs/v3/chat.js.map +1 -1
  15. package/dist/commonjs/v3/chat.test.js +53 -0
  16. package/dist/commonjs/v3/chat.test.js.map +1 -1
  17. package/dist/commonjs/v3/createStartSessionAction.test.js +30 -0
  18. package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -1
  19. package/dist/commonjs/v3/sessions.d.ts +11 -6
  20. package/dist/commonjs/v3/sessions.js +10 -5
  21. package/dist/commonjs/v3/sessions.js.map +1 -1
  22. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +6 -0
  23. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  24. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  25. package/dist/commonjs/version.js +1 -1
  26. package/dist/esm/v3/ai.d.ts +178 -5
  27. package/dist/esm/v3/ai.js +603 -120
  28. package/dist/esm/v3/ai.js.map +1 -1
  29. package/dist/esm/v3/chat-client.js +3 -0
  30. package/dist/esm/v3/chat-client.js.map +1 -1
  31. package/dist/esm/v3/chat-react.js +10 -7
  32. package/dist/esm/v3/chat-react.js.map +1 -1
  33. package/dist/esm/v3/chat-server.d.ts +8 -0
  34. package/dist/esm/v3/chat-server.js +32 -10
  35. package/dist/esm/v3/chat-server.js.map +1 -1
  36. package/dist/esm/v3/chat-server.test.js +51 -0
  37. package/dist/esm/v3/chat-server.test.js.map +1 -1
  38. package/dist/esm/v3/chat.js +34 -6
  39. package/dist/esm/v3/chat.js.map +1 -1
  40. package/dist/esm/v3/chat.test.js +53 -0
  41. package/dist/esm/v3/chat.test.js.map +1 -1
  42. package/dist/esm/v3/createStartSessionAction.test.js +30 -0
  43. package/dist/esm/v3/createStartSessionAction.test.js.map +1 -1
  44. package/dist/esm/v3/sessions.d.ts +11 -6
  45. package/dist/esm/v3/sessions.js +10 -5
  46. package/dist/esm/v3/sessions.js.map +1 -1
  47. package/dist/esm/v3/test/mock-chat-agent.d.ts +6 -0
  48. package/dist/esm/v3/test/mock-chat-agent.js +1 -0
  49. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  50. package/dist/esm/version.js +1 -1
  51. package/docs/ai/prompts.mdx +430 -0
  52. package/docs/ai-chat/actions.mdx +115 -0
  53. package/docs/ai-chat/anatomy.mdx +71 -0
  54. package/docs/ai-chat/backend.mdx +817 -0
  55. package/docs/ai-chat/background-injection.mdx +221 -0
  56. package/docs/ai-chat/changelog.mdx +850 -0
  57. package/docs/ai-chat/chat-local.mdx +174 -0
  58. package/docs/ai-chat/client-protocol.mdx +1081 -0
  59. package/docs/ai-chat/compaction.mdx +411 -0
  60. package/docs/ai-chat/custom-agents.mdx +364 -0
  61. package/docs/ai-chat/error-handling.mdx +415 -0
  62. package/docs/ai-chat/fast-starts.mdx +672 -0
  63. package/docs/ai-chat/frontend.mdx +580 -0
  64. package/docs/ai-chat/how-it-works.mdx +230 -0
  65. package/docs/ai-chat/lifecycle-hooks.mdx +530 -0
  66. package/docs/ai-chat/mcp.mdx +101 -0
  67. package/docs/ai-chat/overview.mdx +90 -0
  68. package/docs/ai-chat/patterns/branching-conversations.mdx +284 -0
  69. package/docs/ai-chat/patterns/code-sandbox.mdx +126 -0
  70. package/docs/ai-chat/patterns/database-persistence.mdx +414 -0
  71. package/docs/ai-chat/patterns/human-in-the-loop.mdx +275 -0
  72. package/docs/ai-chat/patterns/large-payloads.mdx +169 -0
  73. package/docs/ai-chat/patterns/oom-resilience.mdx +120 -0
  74. package/docs/ai-chat/patterns/persistence-and-replay.mdx +211 -0
  75. package/docs/ai-chat/patterns/recovery-boot.mdx +230 -0
  76. package/docs/ai-chat/patterns/skills.mdx +221 -0
  77. package/docs/ai-chat/patterns/sub-agents.mdx +383 -0
  78. package/docs/ai-chat/patterns/tool-result-auditing.mdx +148 -0
  79. package/docs/ai-chat/patterns/trusted-edge-signals.mdx +337 -0
  80. package/docs/ai-chat/patterns/version-upgrades.mdx +172 -0
  81. package/docs/ai-chat/pending-messages.mdx +343 -0
  82. package/docs/ai-chat/prompt-caching.mdx +206 -0
  83. package/docs/ai-chat/quick-start.mdx +161 -0
  84. package/docs/ai-chat/reference.mdx +909 -0
  85. package/docs/ai-chat/server-chat.mdx +263 -0
  86. package/docs/ai-chat/sessions.mdx +333 -0
  87. package/docs/ai-chat/testing.mdx +682 -0
  88. package/docs/ai-chat/tools.mdx +191 -0
  89. package/docs/ai-chat/types.mdx +242 -0
  90. package/docs/ai-chat/upgrade-guide.mdx +515 -0
  91. package/docs/apikeys.mdx +54 -0
  92. package/docs/building-with-ai.mdx +261 -0
  93. package/docs/bulk-actions.mdx +49 -0
  94. package/docs/changelog.mdx +6 -0
  95. package/docs/cli-deploy-commands.mdx +9 -0
  96. package/docs/cli-dev-commands.mdx +9 -0
  97. package/docs/cli-dev.mdx +8 -0
  98. package/docs/cli-init-commands.mdx +58 -0
  99. package/docs/cli-introduction.mdx +25 -0
  100. package/docs/cli-list-profiles-commands.mdx +42 -0
  101. package/docs/cli-login-commands.mdx +33 -0
  102. package/docs/cli-logout-commands.mdx +33 -0
  103. package/docs/cli-preview-archive.mdx +59 -0
  104. package/docs/cli-promote-commands.mdx +9 -0
  105. package/docs/cli-switch.mdx +43 -0
  106. package/docs/cli-update-commands.mdx +42 -0
  107. package/docs/cli-whoami-commands.mdx +33 -0
  108. package/docs/community.mdx +6 -0
  109. package/docs/config/config-file.mdx +602 -0
  110. package/docs/config/extensions/additionalFiles.mdx +38 -0
  111. package/docs/config/extensions/additionalPackages.mdx +40 -0
  112. package/docs/config/extensions/aptGet.mdx +34 -0
  113. package/docs/config/extensions/audioWaveform.mdx +20 -0
  114. package/docs/config/extensions/custom.mdx +380 -0
  115. package/docs/config/extensions/emitDecoratorMetadata.mdx +29 -0
  116. package/docs/config/extensions/esbuildPlugin.mdx +31 -0
  117. package/docs/config/extensions/ffmpeg.mdx +45 -0
  118. package/docs/config/extensions/lightpanda.mdx +56 -0
  119. package/docs/config/extensions/overview.mdx +67 -0
  120. package/docs/config/extensions/playwright.mdx +195 -0
  121. package/docs/config/extensions/prismaExtension.mdx +1014 -0
  122. package/docs/config/extensions/puppeteer.mdx +30 -0
  123. package/docs/config/extensions/pythonExtension.mdx +182 -0
  124. package/docs/config/extensions/syncEnvVars.mdx +291 -0
  125. package/docs/context.mdx +235 -0
  126. package/docs/database-connections.mdx +213 -0
  127. package/docs/deploy-environment-variables.mdx +435 -0
  128. package/docs/deployment/atomic-deployment.mdx +172 -0
  129. package/docs/deployment/overview.mdx +257 -0
  130. package/docs/deployment/preview-branches.mdx +224 -0
  131. package/docs/errors-retrying.mdx +379 -0
  132. package/docs/github-actions.mdx +222 -0
  133. package/docs/github-integration.mdx +136 -0
  134. package/docs/github-repo.mdx +8 -0
  135. package/docs/help-email.mdx +6 -0
  136. package/docs/help-slack.mdx +11 -0
  137. package/docs/hidden-tasks.mdx +56 -0
  138. package/docs/how-it-works.mdx +454 -0
  139. package/docs/how-to-reduce-your-spend.mdx +217 -0
  140. package/docs/idempotency.mdx +504 -0
  141. package/docs/introduction.mdx +223 -0
  142. package/docs/limits.mdx +241 -0
  143. package/docs/logging.mdx +195 -0
  144. package/docs/machines.mdx +952 -0
  145. package/docs/manual-setup.mdx +632 -0
  146. package/docs/mcp-agent-rules.mdx +41 -0
  147. package/docs/mcp-introduction.mdx +385 -0
  148. package/docs/mcp-tools.mdx +273 -0
  149. package/docs/migrating-from-v3.mdx +334 -0
  150. package/docs/observability/dashboards.mdx +102 -0
  151. package/docs/observability/query.mdx +585 -0
  152. package/docs/open-source-contributing.mdx +16 -0
  153. package/docs/open-source-self-hosting.mdx +541 -0
  154. package/docs/private-networking/aws-console-setup.mdx +304 -0
  155. package/docs/private-networking/overview.mdx +144 -0
  156. package/docs/private-networking/troubleshooting.mdx +78 -0
  157. package/docs/queue-concurrency.mdx +354 -0
  158. package/docs/quick-start.mdx +97 -0
  159. package/docs/realtime/auth.mdx +208 -0
  160. package/docs/realtime/backend/overview.mdx +45 -0
  161. package/docs/realtime/backend/streams.mdx +418 -0
  162. package/docs/realtime/backend/subscribe.mdx +225 -0
  163. package/docs/realtime/how-it-works.mdx +94 -0
  164. package/docs/realtime/overview.mdx +63 -0
  165. package/docs/realtime/react-hooks/overview.mdx +73 -0
  166. package/docs/realtime/react-hooks/streams.mdx +449 -0
  167. package/docs/realtime/react-hooks/subscribe.mdx +674 -0
  168. package/docs/realtime/react-hooks/swr.mdx +87 -0
  169. package/docs/realtime/react-hooks/triggering.mdx +194 -0
  170. package/docs/realtime/react-hooks/use-wait-token.mdx +34 -0
  171. package/docs/realtime/run-object.mdx +174 -0
  172. package/docs/replaying.mdx +72 -0
  173. package/docs/request-feature.mdx +6 -0
  174. package/docs/roadmap.mdx +6 -0
  175. package/docs/run-tests.mdx +20 -0
  176. package/docs/run-usage.mdx +113 -0
  177. package/docs/runs/heartbeats.mdx +38 -0
  178. package/docs/runs/max-duration.mdx +139 -0
  179. package/docs/runs/metadata.mdx +734 -0
  180. package/docs/runs/priority.mdx +31 -0
  181. package/docs/runs.mdx +396 -0
  182. package/docs/self-hosting/docker.mdx +458 -0
  183. package/docs/self-hosting/env/supervisor.mdx +74 -0
  184. package/docs/self-hosting/env/webapp.mdx +276 -0
  185. package/docs/self-hosting/kubernetes.mdx +601 -0
  186. package/docs/self-hosting/overview.mdx +108 -0
  187. package/docs/skills.mdx +85 -0
  188. package/docs/tags.mdx +120 -0
  189. package/docs/tasks/overview.mdx +697 -0
  190. package/docs/tasks/scheduled.mdx +382 -0
  191. package/docs/tasks/schemaTask.mdx +413 -0
  192. package/docs/tasks/streams.mdx +884 -0
  193. package/docs/triggering.mdx +1320 -0
  194. package/docs/troubleshooting-alerts.mdx +385 -0
  195. package/docs/troubleshooting-debugging-in-vscode.mdx +8 -0
  196. package/docs/troubleshooting-github-issues.mdx +6 -0
  197. package/docs/troubleshooting-uptime-status.mdx +6 -0
  198. package/docs/troubleshooting.mdx +398 -0
  199. package/docs/upgrading-packages.mdx +80 -0
  200. package/docs/vercel-integration.mdx +207 -0
  201. package/docs/versioning.mdx +56 -0
  202. package/docs/video-walkthrough.mdx +23 -0
  203. package/docs/wait-for-token.mdx +540 -0
  204. package/docs/wait-for.mdx +42 -0
  205. package/docs/wait-until.mdx +53 -0
  206. package/docs/wait.mdx +18 -0
  207. package/docs/writing-tasks-introduction.mdx +33 -0
  208. package/package.json +10 -6
  209. package/skills/trigger-authoring-chat-agent/SKILL.md +296 -0
  210. package/skills/trigger-authoring-tasks/SKILL.md +254 -0
  211. package/skills/trigger-chat-agent-advanced/SKILL.md +368 -0
  212. package/skills/trigger-cost-savings/SKILL.md +116 -0
  213. package/skills/trigger-realtime-and-frontend/SKILL.md +276 -0
@@ -0,0 +1,682 @@
1
+ ---
2
+ title: "Testing"
3
+ sidebarTitle: "Testing"
4
+ description: "Drive a chat.agent through real turns in unit tests — no network, no task runtime, no mocking the SDK."
5
+ ---
6
+
7
+ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
8
+
9
+ <RcBanner />
10
+
11
+ ## Overview
12
+
13
+ `@trigger.dev/sdk/ai/test` exports `mockChatAgent`, an offline harness that runs your `chat.agent` definition's `run()` function inside an in-memory task runtime. You send messages, actions, and stop signals through driver methods and assert against the chunks the agent emits.
14
+
15
+ Under the hood the harness drives the agent's backing Session channels — `.in` receives the records your `sendMessage` / `sendStop` / `sendAction` produce, `.out` captures the chunks the agent emits. The harness API itself is session-agnostic; you don't need to manage `sessionId` in tests.
16
+
17
+ The harness exercises the real turn loop, lifecycle hooks, validation, hydration, and action routing — only the language model and the surrounding Trigger.dev runtime are replaced. Pair it with [`MockLanguageModelV3`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/mock-language-model-v3) and `simulateReadableStream` from `ai` to control LLM responses.
18
+
19
+ <Note>
20
+ Import `@trigger.dev/sdk/ai/test` **before** your agent module. It installs the resource catalog so `chat.agent({ id, ... })` can register tasks during testing.
21
+ </Note>
22
+
23
+ ## Quick start
24
+
25
+ ```ts trigger/my-chat.test.ts
26
+ import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
27
+
28
+ import { describe, expect, it } from "vitest";
29
+ import { simulateReadableStream, stepCountIs } from "ai";
30
+ import { MockLanguageModelV3 } from "ai/test";
31
+ import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
32
+ import { myChatAgent } from "./my-chat.js";
33
+
34
+ function modelWithText(text: string) {
35
+ const chunks: LanguageModelV3StreamPart[] = [
36
+ { type: "text-start", id: "t1" },
37
+ { type: "text-delta", id: "t1", delta: text },
38
+ { type: "text-end", id: "t1" },
39
+ {
40
+ type: "finish",
41
+ finishReason: { unified: "stop", raw: "stop" },
42
+ usage: {
43
+ inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
44
+ outputTokens: { total: 10, text: 10, reasoning: undefined },
45
+ },
46
+ },
47
+ ];
48
+ return new MockLanguageModelV3({
49
+ doStream: async () => ({ stream: simulateReadableStream({ chunks }) }),
50
+ });
51
+ }
52
+
53
+ describe("myChatAgent", () => {
54
+ it("streams the model's response", async () => {
55
+ const model = modelWithText("hello world");
56
+ const harness = mockChatAgent(myChatAgent, {
57
+ chatId: "test-1",
58
+ clientData: { model },
59
+ });
60
+
61
+ try {
62
+ const turn = await harness.sendMessage({
63
+ id: "u1",
64
+ role: "user",
65
+ parts: [{ type: "text", text: "hi" }],
66
+ });
67
+
68
+ const text = turn.chunks
69
+ .filter((c) => c.type === "text-delta")
70
+ .map((c) => (c as { delta: string }).delta)
71
+ .join("");
72
+ expect(text).toBe("hello world");
73
+ } finally {
74
+ await harness.close();
75
+ }
76
+ });
77
+ });
78
+ ```
79
+
80
+ The agent reads the mock model from `clientData`:
81
+
82
+ ```ts trigger/my-chat.ts
83
+ import { chat } from "@trigger.dev/sdk/ai";
84
+ import { streamText, type LanguageModel } from "ai";
85
+ import { z } from "zod";
86
+
87
+ type ClientData = { model: LanguageModel };
88
+
89
+ export const myChatAgent = chat
90
+ .withClientData({
91
+ schema: z.custom<ClientData>(
92
+ (v) => !!v && typeof v === "object" && "model" in (v as object)
93
+ ),
94
+ })
95
+ .agent({
96
+ id: "my-chat",
97
+ run: async ({ messages, clientData, signal }) => {
98
+ return streamText({
99
+ model: clientData?.model ?? "openai/gpt-4o-mini",
100
+ messages,
101
+ abortSignal: signal,
102
+ stopWhen: stepCountIs(15),
103
+ });
104
+ },
105
+ });
106
+ ```
107
+
108
+ ## Setup
109
+
110
+ ### Install dev dependencies
111
+
112
+ The harness itself ships with `@trigger.dev/sdk`. You need a test runner and the AI SDK's mock model utilities:
113
+
114
+ ```bash
115
+ pnpm add -D vitest ai @ai-sdk/provider
116
+ ```
117
+
118
+ `@ai-sdk/provider` is only needed to type the chunk array as `LanguageModelV3StreamPart[]` — drop it if you cast inline.
119
+
120
+ ### Vitest config
121
+
122
+ A minimal `vitest.config.ts` for a Trigger.dev project:
123
+
124
+ ```ts
125
+ import { defineConfig } from "vitest/config";
126
+
127
+ export default defineConfig({
128
+ test: {
129
+ include: ["src/**/*.test.ts"],
130
+ environment: "node",
131
+ },
132
+ });
133
+ ```
134
+
135
+ ### Import order
136
+
137
+ `mockChatAgent` must be imported **first** so the resource catalog is installed before any `chat.agent({ id, ... })` registration runs:
138
+
139
+ ```ts
140
+ // ✅ Correct
141
+ import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
142
+ import { myAgent } from "./my-agent.js";
143
+
144
+ // ❌ Wrong — agent loads before the catalog exists
145
+ import { myAgent } from "./my-agent.js";
146
+ import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
147
+ ```
148
+
149
+ If the agent isn't registered when `mockChatAgent` runs, you'll get:
150
+
151
+ ```
152
+ mockChatAgent: no task registered with id "my-chat".
153
+ ```
154
+
155
+ ## Inject the model via clientData
156
+
157
+ `MockLanguageModelV3` lives in test code and shouldn't leak into your agent module. Pass it through `clientData` so the agent picks it up at runtime in tests, and falls back to a real model in production:
158
+
159
+ ```ts trigger/agent.ts
160
+ type ClientData = { model?: LanguageModel };
161
+
162
+ export const agent = chat
163
+ .withClientData({ schema: z.custom<ClientData>() })
164
+ .agent({
165
+ id: "agent",
166
+ run: async ({ messages, clientData, signal }) => {
167
+ return streamText({
168
+ model: clientData?.model ?? anthropic("claude-haiku-4-5"),
169
+ messages,
170
+ abortSignal: signal,
171
+ stopWhen: stepCountIs(15),
172
+ });
173
+ },
174
+ });
175
+ ```
176
+
177
+ ```ts agent.test.ts
178
+ const harness = mockChatAgent(agent, {
179
+ chatId: "test",
180
+ clientData: { model: mockModel },
181
+ });
182
+ ```
183
+
184
+ ## Driving turns
185
+
186
+ The harness exposes one method per chat trigger. Each waits for the next `trigger:turn-complete` chunk before resolving.
187
+
188
+ ### sendMessage
189
+
190
+ ```ts
191
+ const turn = await harness.sendMessage({
192
+ id: "u1",
193
+ role: "user",
194
+ parts: [{ type: "text", text: "hi" }],
195
+ });
196
+ ```
197
+
198
+ Pass an array to send multiple messages at once.
199
+
200
+ ### sendRegenerate
201
+
202
+ ```ts
203
+ const turn = await harness.sendRegenerate(messages);
204
+ ```
205
+
206
+ Equivalent to the frontend's `useChat().regenerate()` — replays a turn with the given message history.
207
+
208
+ ### sendAction
209
+
210
+ Routes a payload through `actionSchema` + `onAction`. Actions are not turns: only `hydrateMessages` and `onAction` fire on the agent side — no turn lifecycle hooks, no `run()`. The returned `turn.rawChunks` contains whatever `onAction` produced (a streamed model response if it returned a `StreamTextResult`, otherwise just `trigger:turn-complete`):
211
+
212
+ ```ts
213
+ const turn = await harness.sendAction({ type: "undo" });
214
+ ```
215
+
216
+ If the action fails schema validation, an `error` chunk appears in `turn.rawChunks`.
217
+
218
+ ### sendStop
219
+
220
+ Fires a stop signal. Does **not** wait for a turn — the agent's `signal.aborted` becomes `true` and the current turn unwinds:
221
+
222
+ ```ts
223
+ await harness.sendStop("user requested stop");
224
+ ```
225
+
226
+ ### close
227
+
228
+ Sends a `close` trigger, closes the session's `.in` channel, and aborts the run signal so the task exits cleanly. Always call this at the end of every test:
229
+
230
+ ```ts
231
+ afterEach(() => harness.close());
232
+ // or with a try/finally
233
+ try {
234
+ await harness.sendMessage(...);
235
+ } finally {
236
+ await harness.close();
237
+ }
238
+ ```
239
+
240
+ ## Inspecting output
241
+
242
+ Each turn returns:
243
+
244
+ ```ts
245
+ type MockChatAgentTurn = {
246
+ chunks: UIMessageChunk[]; // text-delta, tool-call, etc.
247
+ rawChunks: unknown[]; // includes control chunks (turn-complete, errors)
248
+ };
249
+ ```
250
+
251
+ The harness also exposes accumulators across all turns:
252
+
253
+ ```ts
254
+ harness.allChunks; // every UIMessageChunk since creation
255
+ harness.allRawChunks; // every raw chunk including control frames
256
+ ```
257
+
258
+ A small helper to assemble streamed text:
259
+
260
+ ```ts
261
+ function collectText(chunks: UIMessageChunk[]): string {
262
+ return chunks
263
+ .filter((c) => c.type === "text-delta")
264
+ .map((c) => (c as { delta: string }).delta)
265
+ .join("");
266
+ }
267
+ ```
268
+
269
+ ## Common patterns
270
+
271
+ ### Asserting hook order
272
+
273
+ ```ts
274
+ const events: string[] = [];
275
+ const agent = chat.agent({
276
+ id: "hook-order",
277
+ onChatStart: async () => { events.push("onChatStart"); },
278
+ onTurnStart: async () => { events.push("onTurnStart"); },
279
+ onBeforeTurnComplete: async () => { events.push("onBeforeTurnComplete"); },
280
+ onTurnComplete: async () => { events.push("onTurnComplete"); },
281
+ run: async ({ messages, signal }) => {
282
+ events.push("run");
283
+ return streamText({ model, messages, abortSignal: signal });
284
+ },
285
+ });
286
+
287
+ const harness = mockChatAgent(agent, { chatId: "t" });
288
+ await harness.sendMessage(userMessage("hi"));
289
+
290
+ // onTurnComplete fires after the turn-complete chunk is written —
291
+ // give it a tick before asserting.
292
+ await new Promise((r) => setTimeout(r, 20));
293
+ expect(events).toEqual([
294
+ "onChatStart",
295
+ "onTurnStart",
296
+ "run",
297
+ "onBeforeTurnComplete",
298
+ "onTurnComplete",
299
+ ]);
300
+ await harness.close();
301
+ ```
302
+
303
+ ### Testing onValidateMessages
304
+
305
+ ```ts
306
+ const turn = await harness.sendMessage(userMessage("hello blocked-word"));
307
+
308
+ // The turn completes with an error chunk, not text
309
+ expect(collectText(turn.chunks)).toBe("");
310
+ expect(turn.rawChunks.some((c) =>
311
+ typeof c === "object" && c !== null &&
312
+ (c as { type?: string }).type === "trigger:turn-complete"
313
+ )).toBe(true);
314
+ ```
315
+
316
+ ### Testing actions and rejection
317
+
318
+ ```ts
319
+ // Valid action
320
+ await harness.sendAction({ type: "undo" });
321
+
322
+ // Invalid action — schema validation fails, error chunk emitted
323
+ const turn = await harness.sendAction({ type: "not-a-real-action" });
324
+ const errors = turn.rawChunks.filter((c) =>
325
+ typeof c === "object" && c !== null &&
326
+ (c as { type?: string }).type === "error"
327
+ );
328
+ expect(errors.length).toBeGreaterThan(0);
329
+ ```
330
+
331
+ ### Multi-turn accumulation
332
+
333
+ The harness preserves chat history across turns, just like the real runtime:
334
+
335
+ ```ts
336
+ const seenLengths: number[] = [];
337
+ const agent = chat.agent({
338
+ id: "multi-turn",
339
+ run: async ({ messages, signal }) => {
340
+ seenLengths.push(messages.length);
341
+ return streamText({ model, messages, abortSignal: signal });
342
+ },
343
+ });
344
+
345
+ const harness = mockChatAgent(agent, { chatId: "t" });
346
+ await harness.sendMessage(userMessage("first"));
347
+ await harness.sendMessage(userMessage("second"));
348
+ await harness.sendMessage(userMessage("third"));
349
+
350
+ // Turn 1: 1 message; turn 2: user + assistant + user = 3; turn 3: 5
351
+ expect(seenLengths).toEqual([1, 3, 5]);
352
+ ```
353
+
354
+ ### Hydrating from a "database"
355
+
356
+ Use `clientData` to seed a synthetic prior context for `hydrateMessages`:
357
+
358
+ ```ts
359
+ const hydrated = [
360
+ { id: "h1", role: "user", parts: [{ type: "text", text: "prior question" }] },
361
+ { id: "h2", role: "assistant", parts: [{ type: "text", text: "prior answer" }] },
362
+ ];
363
+
364
+ const harness = mockChatAgent(agent, {
365
+ chatId: "test-hydrate",
366
+ clientData: { model, hydrated: [...hydrated, userMessage("follow up")] },
367
+ });
368
+
369
+ await harness.sendMessage(userMessage("follow up"));
370
+
371
+ // Model should have been called with the hydrated context
372
+ expect(model.doStreamCalls[0]!.prompt.length).toBeGreaterThanOrEqual(3);
373
+ ```
374
+
375
+ The agent reads `clientData.hydrated` inside its `hydrateMessages` hook:
376
+
377
+ ```ts
378
+ hydrateMessages: async ({ clientData, incomingMessages }) => {
379
+ return clientData?.hydrated ?? incomingMessages;
380
+ },
381
+ ```
382
+
383
+ ### Testing continuation runs
384
+
385
+ A continuation run is a new run picking up an existing session after the prior run ended — `chat.endRun`, waitpoint timeout, or `chat.requestUpgrade`. The contract differs from a fresh run in two ways:
386
+
387
+ - `onChatStart` does **not** fire (it's once-per-chat — fires only on the chat's very first user message ever).
388
+ - The boot payload arrives with `continuation: true` and no `message`. The SDK waits silently on `session.in` until the next user message arrives.
389
+
390
+ Pass `continuation: true` to drive this path:
391
+
392
+ ```ts
393
+ const onChatStart = vi.fn();
394
+ const onTurnStart = vi.fn();
395
+
396
+ const agent = chat.agent({
397
+ id: "my-chat",
398
+ onChatStart,
399
+ onTurnStart,
400
+ run: async ({ messages, signal }) =>
401
+ streamText({ model, messages, abortSignal: signal }),
402
+ });
403
+
404
+ const harness = mockChatAgent(agent, {
405
+ chatId: "test-continuation",
406
+ // Auto-selects `mode: "continuation"` — boots with `trigger` omitted
407
+ // and `continuation: true` in the wire payload, exactly as the server
408
+ // produces it on continuation runs in production.
409
+ continuation: true,
410
+ previousRunId: "run_test_prior",
411
+ });
412
+
413
+ try {
414
+ // The SDK enters continuation-wait; sendMessage wakes it and drives turn 0.
415
+ await harness.sendMessage({
416
+ id: "u1",
417
+ role: "user",
418
+ parts: [{ type: "text", text: "where were we?" }],
419
+ });
420
+ await new Promise((r) => setTimeout(r, 20));
421
+
422
+ expect(onChatStart).not.toHaveBeenCalled();
423
+ expect(onTurnStart).toHaveBeenCalledTimes(1);
424
+ } finally {
425
+ await harness.close();
426
+ }
427
+ ```
428
+
429
+ To simulate an **OOM-retry attempt** (also a continuation by contract — same `onChatStart` skip), bump `ctx.attempt.number`:
430
+
431
+ ```ts
432
+ const harness = mockChatAgent(agent, {
433
+ chatId: "test-oom-retry",
434
+ taskContext: {
435
+ ctx: { attempt: { number: 2, startedAt: new Date(0), status: "EXECUTING" } },
436
+ },
437
+ });
438
+
439
+ await harness.sendMessage(/* ... */);
440
+ expect(onChatStart).not.toHaveBeenCalled();
441
+ ```
442
+
443
+ ### Testing recovery boot
444
+
445
+ `onRecoveryBoot` fires when the dead predecessor left state behind — a partial assistant on `session.out`, in-flight users on `session.in`, or both. The harness exposes two seeders to drive this state at boot time:
446
+
447
+ - `harness.seedSessionOutPartial(message)` — pre-seed a trailing partial assistant. The next boot's replay surfaces it as `event.partialAssistant`.
448
+ - `harness.seedSessionInTail(messages)` — pre-seed user messages on the input tail. The next boot's replay surfaces them as `event.inFlightUsers`.
449
+
450
+ Combined with `continuation: true`, this drives the full recovery boot path:
451
+
452
+ ```ts
453
+ import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
454
+
455
+ const onRecoveryBoot = vi.fn(async () => {
456
+ // accept smart default
457
+ });
458
+
459
+ const agent = chat.agent({
460
+ id: "my-chat",
461
+ onRecoveryBoot,
462
+ run: async ({ messages, signal }) =>
463
+ streamText({ model, messages, abortSignal: signal }),
464
+ });
465
+
466
+ const harness = mockChatAgent(agent, {
467
+ chatId: "test-recovery",
468
+ continuation: true,
469
+ previousRunId: "run_prior",
470
+ });
471
+
472
+ // Predecessor was answering "write an essay" and got cut off mid-stream
473
+ // after producing some text. Customer then sent a follow-up.
474
+ harness.seedSessionOutPartial({
475
+ id: "a-orphan",
476
+ role: "assistant",
477
+ parts: [{ type: "text", text: "Espresso originated in..." }],
478
+ });
479
+ harness.seedSessionInTail([
480
+ { id: "u-1", role: "user", parts: [{ type: "text", text: "Write an essay about espresso." }] },
481
+ { id: "u-2", role: "user", parts: [{ type: "text", text: "keep going" }] },
482
+ ]);
483
+
484
+ await new Promise((r) => setTimeout(r, 50));
485
+
486
+ expect(onRecoveryBoot).toHaveBeenCalledTimes(1);
487
+ const event = onRecoveryBoot.mock.calls[0]![0];
488
+ expect(event.partialAssistant?.id).toBe("a-orphan");
489
+ expect(event.inFlightUsers).toHaveLength(2);
490
+ ```
491
+
492
+ Use `harness.seedSnapshot({ messages: [...] })` alongside these to model a continuation where settled history exists. See the [Recovery boot](/ai-chat/patterns/recovery-boot) pattern for what each field means and what the smart default does with it.
493
+
494
+ ## Testing against a database
495
+
496
+ Most agents call into a database from `hydrateMessages` or `onTurnComplete` to load history and persist replies. You shouldn't pass database clients through `clientData` — that's wire-data from the browser. Use **`locals` for dependency injection** instead.
497
+
498
+ `locals` are task-scoped, server-side only, and untyped to the wire format. The mock harness exposes a `setupLocals` callback that pre-seeds them before the agent's `run()` starts.
499
+
500
+ ### Define a locals key for the dependency
501
+
502
+ Create a single key per dependency, exported from your project:
503
+
504
+ ```ts db.ts
505
+ import { locals } from "@trigger.dev/sdk";
506
+ import { PrismaClient } from "@prisma/client";
507
+
508
+ export type Db = PrismaClient;
509
+ export const dbKey = locals.create<Db>("db");
510
+
511
+ export function getDb(): Db {
512
+ // Returns the seeded test instance if present, otherwise lazy-creates prod.
513
+ return locals.get(dbKey) ?? locals.set(dbKey, new PrismaClient());
514
+ }
515
+ ```
516
+
517
+ ### Use the dependency from agent hooks
518
+
519
+ Hooks read from `locals` instead of constructing clients themselves:
520
+
521
+ ```ts trigger/agent.ts
522
+ import { chat } from "@trigger.dev/sdk/ai";
523
+ import { getDb } from "../db";
524
+
525
+ export const agent = chat.agent({
526
+ id: "agent",
527
+ hydrateMessages: async ({ chatId }) => {
528
+ const db = getDb();
529
+ const row = await db.chat.findUnique({ where: { id: chatId } });
530
+ return (row?.messages as UIMessage[]) ?? [];
531
+ },
532
+ onTurnComplete: async ({ chatId, messages }) => {
533
+ const db = getDb();
534
+ await db.chat.upsert({
535
+ where: { id: chatId },
536
+ create: { id: chatId, messages },
537
+ update: { messages },
538
+ });
539
+ },
540
+ run: async ({ messages, signal }) => {
541
+ return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
542
+ },
543
+ });
544
+ ```
545
+
546
+ ### Inject a test database in the harness
547
+
548
+ `setupLocals` runs *before* the agent starts, so `getDb()` returns the test instance for every hook:
549
+
550
+ ```ts agent.test.ts
551
+ import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
552
+ import { dbKey } from "./db";
553
+ import { agent } from "./trigger/agent";
554
+
555
+ const harness = mockChatAgent(agent, {
556
+ chatId: "test-1",
557
+ setupLocals: ({ set }) => {
558
+ set(dbKey, testDb); // testDb = your testcontainers Prisma client, sqlite stub, etc.
559
+ },
560
+ });
561
+ ```
562
+
563
+ ### Pick a backing database
564
+
565
+ You still need to decide what `testDb` actually is:
566
+
567
+ - **Testcontainers (recommended).** Spin up Postgres in Docker via `@internal/testcontainers` (or `testcontainers` directly), run migrations, hand the resulting `PrismaClient` to `set(dbKey, ...)`. Highest fidelity — catches schema drift, migration bugs, transaction issues.
568
+ - **Embedded SQLite / PGlite.** Fast and no Docker, but a different SQL dialect from production. Fine for hooks that only do simple CRUD; risky for raw SQL or Postgres-specific features.
569
+ - **In-memory fake.** Hand-rolled object with the same interface as your DB module. Fastest, lowest fidelity — works when you only care about whether the agent *called* the right method, not what the DB *did* with it.
570
+
571
+ ### Drizzle, Kysely, etc.
572
+
573
+ The pattern is the same — replace `PrismaClient` with your client class:
574
+
575
+ ```ts db.ts
576
+ import { drizzle } from "drizzle-orm/node-postgres";
577
+ import { Pool } from "pg";
578
+
579
+ export type Db = ReturnType<typeof drizzle>;
580
+ export const dbKey = locals.create<Db>("db");
581
+
582
+ export function getDb(): Db {
583
+ return locals.get(dbKey) ?? locals.set(
584
+ dbKey,
585
+ drizzle(new Pool({ connectionString: process.env.DATABASE_URL })),
586
+ );
587
+ }
588
+ ```
589
+
590
+ <Tip>
591
+ The same `setupLocals` pattern works for any server-side dependency: feature flag clients, Stripe SDK, internal HTTP clients, Sentry. Anything you'd normally inject via constructor parameters in a class-based design.
592
+ </Tip>
593
+
594
+ ## API reference
595
+
596
+ ### mockChatAgent(agent, options?)
597
+
598
+ ```ts
599
+ function mockChatAgent(
600
+ agent: { id: string },
601
+ options?: MockChatAgentOptions,
602
+ ): MockChatAgentHarness;
603
+ ```
604
+
605
+ #### MockChatAgentOptions
606
+
607
+ | Option | Type | Default | Description |
608
+ | ---------------- | --------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------ |
609
+ | `chatId` | `string` | `"test-chat"` | Chat session id passed in every wire payload. |
610
+ | `clientData` | `unknown` | `undefined` | Client-provided data forwarded to `run()` and every hook. |
611
+ | `taskContext` | `MockTaskContextOptions` | `{}` | Overrides for the mock `TaskRunContext`. Use `ctx.attempt.number > 1` to simulate an OOM-retry attempt — the agent skips `onChatStart` (same as continuation runs). |
612
+ | `preload` | `boolean` | `true` | Start in preload mode. When `false`, the first `sendMessage()` starts turn 0 directly without preload. Ignored when `mode` is set explicitly. |
613
+ | `mode` | `"preload" \| "submit-message" \| "handover-prepare" \| "continuation"` | derived | Initial boot trigger. Defaults to `"preload"` (or `"submit-message"` when `preload: false`, or `"continuation"` when `continuation: true`). See [Boot modes](#boot-modes) below. |
614
+ | `continuation` | `boolean` | `false` | Boot as a continuation run (a new run on an existing session). Auto-selects `mode: "continuation"` if `mode` is not set — boots with `trigger` omitted and `continuation: true` in the payload, exercising the SDK's continuation-wait branch. `onChatStart` does NOT fire on continuation runs. |
615
+ | `previousRunId` | `string` | `undefined` | Set `payload.previousRunId` on the initial wire payload. Typically paired with `continuation: true`. |
616
+ | `snapshot` | `ChatSnapshotV1` | `undefined` | Pre-seed the snapshot the agent reads at run boot (replaces the real S3 GET). Use to drive resume scenarios with prior history. See [Persistence and replay](/ai-chat/patterns/persistence-and-replay) for the production snapshot model. |
617
+ | `setupLocals` | `({ set }) => void \| Promise<void>` | `undefined` | Callback invoked before `run()` starts. Use `set(key, value)` to inject server-side dependencies (DB clients, service stubs) that the agent reads via `locals.get()`. |
618
+
619
+ ##### Boot modes
620
+
621
+ The harness's initial wire payload depends on `mode`:
622
+
623
+ | Mode | Wire payload | Use when |
624
+ | --------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
625
+ | `"preload"` | `{ trigger: "preload" }` | Simulating a `transport.preload(chatId)` warm-up. Fires `onPreload`, waits for the first `sendMessage()`. |
626
+ | `"submit-message"` | `{ trigger: "submit-message" }` | Skipping preload — `sendMessage()` drives turn 0 directly. |
627
+ | `"continuation"` | `{ continuation: true }` (no `trigger`) | A new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). Mirrors the boot payload the server's `ensureRunForSession` / `swapSessionRun` produce. The SDK enters its continuation-wait branch — `onPreload` and `onChatStart` do NOT fire. |
628
+ | `"handover-prepare"` | `{ trigger: "handover-prepare" }` | Driving the `chat.handover` wait path. Use `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal. |
629
+
630
+ #### MockChatAgentHarness
631
+
632
+ | Member | Description |
633
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------ |
634
+ | `chatId` | The chat session id used by this harness. |
635
+ | `sendMessage(message)` | Send a single user message (or tool-approval-responded assistant message). Slim wire: at most ONE message per record. Returns the chunks produced during the resulting turn. |
636
+ | `sendRegenerate()` | Send a regenerate-message trigger (no body — slim wire). The agent trims trailing assistant messages from its accumulator and re-runs. |
637
+ | `sendHeadStart({ messages })` | 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. |
638
+ | `sendHandover({ partialAssistantMessage, isFinal?, messageId? })` | Dispatch a `handover` signal — only meaningful when started with `mode: "handover-prepare"`. The agent picks up partial assistant messages and continues the turn. |
639
+ | `sendHandoverSkip()` | Dispatch a `handover-skip` signal — only meaningful when started with `mode: "handover-prepare"`. The agent exits cleanly without firing turn hooks. |
640
+ | `sendAction(action)` | Route a custom action through `actionSchema` + `onAction`. |
641
+ | `sendStop(message?)` | Fire a stop signal. Does not wait for the turn — the run's `signal.aborted` becomes `true`. |
642
+ | `seedSnapshot(snapshot)` | Pre-seed the snapshot read for the next boot. Effective on the next run boot only. |
643
+ | `seedSessionOutTail(chunks?)` | Pre-seed `session.out` chunks for the next boot's replay. Reduces to settled assistant turns. |
644
+ | `seedSessionOutPartial(message?)` | Pre-seed a trailing partial assistant for the next boot's replay. Surfaces as `event.partialAssistant` in `onRecoveryBoot`. |
645
+ | `seedSessionInTail(messages)` | Pre-seed user messages on `session.in` for the next boot. Surfaces as `event.inFlightUsers` in `onRecoveryBoot`. |
646
+ | `getSnapshot()` | The most recently written snapshot, or `undefined` if no snapshot was written. |
647
+ | `close()` | Send a `close` trigger, abort the signal, wait for `run()` to return. Always call at end of test. |
648
+ | `allChunks` | Every `UIMessageChunk` emitted since the harness was created. |
649
+ | `allRawChunks` | Every raw chunk emitted since creation, including control chunks (`trigger:turn-complete`, errors). |
650
+
651
+ ### runInMockTaskContext
652
+
653
+ `mockChatAgent` is a higher-level wrapper around `runInMockTaskContext`, re-exported from `@trigger.dev/sdk/ai/test` so you don't need to depend on `@trigger.dev/core` directly. Use it when you need to drive a non-chat task offline:
654
+
655
+ ```ts
656
+ import { runInMockTaskContext } from "@trigger.dev/sdk/ai/test";
657
+
658
+ await runInMockTaskContext(
659
+ async ({ inputs, outputs, ctx }) => {
660
+ setTimeout(() => {
661
+ inputs.send("chat-messages", { messages: [], chatId: "c1" });
662
+ }, 0);
663
+
664
+ await myTask.fns.run(payload, {
665
+ ctx,
666
+ signal: new AbortController().signal,
667
+ });
668
+
669
+ expect(outputs.chunks("chat")).toContainEqual(
670
+ expect.objectContaining({ type: "text-delta", delta: "hi" }),
671
+ );
672
+ },
673
+ { ctx: { run: { id: "run_abc" } } },
674
+ );
675
+ ```
676
+
677
+ ## Limitations
678
+
679
+ - **No network.** The mock task context replaces realtime streams, run metadata, lifecycle managers, and the runtime. Anything that bypasses these (raw `fetch`, direct DB clients) runs against the real network.
680
+ - **Single agent per process.** The resource catalog is process-global; tests within a file are sequential by default. If you parallelize across files, vitest runs each file in its own worker, which avoids registry collisions.
681
+ - **Time-sensitive hooks.** `onTurnComplete` runs *after* the `turn-complete` chunk is written, so `sendMessage()` resolves before that hook finishes. Add a brief `await new Promise((r) => setTimeout(r, 20))` if you need to assert on hook side-effects.
682
+ - **No real LLM.** The harness does not call providers — you must inject `MockLanguageModelV3` (or another mock) yourself.