@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
package/dist/esm/v3/ai.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessoryAttributes, apiClientManager, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } from "@trigger.dev/core/v3";
1
+ import { accessoryAttributes, apiClientManager, controlSubtype, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } from "@trigger.dev/core/v3";
2
2
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
3
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
4
  import { convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, tool as aiTool, zodSchema, } from "../imports/ai-runtime.js";
@@ -49,6 +49,10 @@ const chatTurnContextKey = locals.create("chat.turnContext");
49
49
  * @internal
50
50
  */
51
51
  const chatSessionHandleKey = locals.create("chat.sessionHandle");
52
+ // The external `chatId` from the boot payload — the value `ToolCallExecutionOptions.chatId`
53
+ // is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
54
+ // metadata reads this directly rather than the Session handle id.
55
+ const chatExternalIdKey = locals.create("chat.externalId");
52
56
  /**
53
57
  * S2 seq_num of the most recent `turn-complete` control record written by
54
58
  * this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -68,51 +72,78 @@ const lastTurnCompleteSeqNumKey = locals.create("chat.lastTurnCompleteSeqNum");
68
72
  * the `.in` subscription so already-processed user messages don't get
69
73
  * replayed from S2.
70
74
  *
71
- * Implementation streams the SSE endpoint and listens for `turn-complete`
72
- * via the transport's `onControl` callback; the data-chunk for-await is
73
- * just there to drive the stream. The scan is O(1 turn) because
74
- * `session.out` is bounded to roughly one turn at steady state every
75
- * successful turn-complete is followed by an S2 trim back to the
76
- * previous one (see `writeTurnCompleteChunk`).
75
+ * Implementation is a non-blocking records read (`wait=0`) the
76
+ * endpoint returns everything currently stored (including pre-trim
77
+ * records, since S2 trims are eventually consistent) in one shot, and
78
+ * we keep the LAST matching header. The previous SSE-based scan had to
79
+ * idle-wait a full 5s window to know it reached the tail, which put a
80
+ * constant ~6s tax on every continuation boot.
77
81
  *
78
82
  * Returns `undefined` if no `turn-complete` carrying the header has been
79
83
  * written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
80
- * or a `turn-complete` written before this header existed (cross-version
81
- * boot). Callers fall back to subscribing `.in` from seq 0 in that case;
82
- * the slim-wire merge handles any dedup against snapshot-restored
83
- * messages.
84
+ * a `turn-complete` written before this header existed, or a server old
85
+ * enough that the records endpoint doesn't serialize headers. Callers
86
+ * fall back to subscribing `.in` from seq 0 in that case; the slim-wire
87
+ * merge handles any dedup against snapshot-restored messages.
84
88
  * @internal
85
89
  */
86
90
  async function findLatestSessionInCursor(chatId) {
87
91
  const apiClient = apiClientManager.clientOrThrow();
92
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
88
93
  let latestCursor;
89
- const stream = await apiClient.subscribeToSessionStream(chatId, "out", {
90
- // 5s rather than 1s: S2 trim is eventually-consistent (10-60s
91
- // window), so a worker booting just after a trim could still see
92
- // pre-trim records and need a bit longer to drain them all before
93
- // the SSE long-poll closes. Without enough headroom the scan would
94
- // fall back to `undefined`, the `.in` cursor wouldn't be seeded,
95
- // and the next subscribe would replay messages already processed.
96
- timeoutInSeconds: 5,
97
- onControl: (event) => {
98
- if (event.subtype !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
99
- return;
100
- const raw = headerValue(event.headers, SESSION_IN_EVENT_ID_HEADER);
101
- if (!raw)
102
- return;
103
- const parsed = Number.parseInt(raw, 10);
104
- if (Number.isFinite(parsed))
105
- latestCursor = parsed;
106
- },
107
- });
108
- // Drain the stream so the underlying SSE reader runs to completion. We
109
- // don't accumulate chunks; `onControl` fires inline as turn-complete
110
- // records arrive.
111
- for await (const _ of stream) {
112
- // intentionally empty
94
+ for (const record of response.records) {
95
+ if (controlSubtype(record.headers) !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
96
+ continue;
97
+ const raw = headerValue(record.headers, SESSION_IN_EVENT_ID_HEADER);
98
+ if (!raw)
99
+ continue;
100
+ const parsed = Number.parseInt(raw, 10);
101
+ if (Number.isFinite(parsed))
102
+ latestCursor = parsed;
113
103
  }
114
104
  return latestCursor;
115
105
  }
106
+ /** Test-only entry point for the records-based cursor scan. @internal */
107
+ export async function __findLatestSessionInCursorForTests(chatId) {
108
+ return findLatestSessionInCursor(chatId);
109
+ }
110
+ /**
111
+ * Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent`
112
+ * raw loops and `chat.createSession`) the way `chat.agent`'s boot does.
113
+ *
114
+ * MUST run before anything attaches a `.in` listener (`createStopSignal`,
115
+ * `chat.messages.on`, the first wait): attaching opens the SSE tail with
116
+ * `Last-Event-ID` from the seeded cursor, so attach-then-seed replays
117
+ * every record from seq 0 — already-answered user messages get delivered
118
+ * into the new run's first wait and the loop re-answers them.
119
+ *
120
+ * Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`,
121
+ * `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the
122
+ * former still re-delivers records the manager buffered before the seed.
123
+ *
124
+ * No-ops on fresh boots and when a cursor is already seeded (e.g. the
125
+ * `chatCustomAgent` wrapper ran before a nested `createChatSession`).
126
+ * @internal
127
+ */
128
+ async function seedSessionInResumeCursorForCustomLoop(payload) {
129
+ if (sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined)
130
+ return;
131
+ // No continuation/attempt gate: the wire may omit `continuation` on a
132
+ // run that still has prior turns (chat.agent covers that case via its
133
+ // snapshot). The scan doubles as the prior-state probe — a fresh
134
+ // session has no turn-complete on `.out`, returns no cursor, and
135
+ // seeds nothing. Cost on fresh boots is one non-blocking records read.
136
+ try {
137
+ const cursor = await findLatestSessionInCursor(payload.chatId);
138
+ if (cursor !== undefined) {
139
+ sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
140
+ sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
141
+ }
142
+ }
143
+ catch (error) {
144
+ logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", { error: error instanceof Error ? error.message : String(error) });
145
+ }
146
+ }
116
147
  let readChatSnapshotImpl;
117
148
  export function __setReadChatSnapshotImplForTests(impl) {
118
149
  readChatSnapshotImpl = impl;
@@ -664,6 +695,16 @@ function createTaskToolExecuteHandler(task) {
664
695
  toolMeta.continuation = chatCtx.continuation;
665
696
  toolMeta.clientData = chatCtx.clientData;
666
697
  }
698
+ else {
699
+ // Hand-rolled chat.customAgent loops never set per-turn context, but
700
+ // the wrapper records the boot payload's external chatId at run boot
701
+ // — thread it so subtask chat helpers (`chat.stream.writer` with
702
+ // target "root") can open the parent's session.
703
+ const chatExternalId = locals.get(chatExternalIdKey);
704
+ if (chatExternalId) {
705
+ toolMeta.chatId = chatExternalId;
706
+ }
707
+ }
667
708
  const chatLocals = {};
668
709
  for (const entry of chatLocalRegistry) {
669
710
  const value = locals.get(entry.key);
@@ -980,8 +1021,15 @@ const messagesInput = {
980
1021
  on(handler) {
981
1022
  return getChatSession().in.on((chunk) => {
982
1023
  if (chunk.kind === "message") {
983
- return handler(chunk.payload);
1024
+ // Returning `true` marks the record CONSUMED at the manager level:
1025
+ // it is neither buffered for a later `once()` nor re-delivered by
1026
+ // the buffer drain when the next turn re-attaches its handler.
1027
+ // Without this, a message arriving mid-stream was delivered twice
1028
+ // and ran a duplicate turn.
1029
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
1030
+ return true;
984
1031
  }
1032
+ return undefined;
985
1033
  });
986
1034
  },
987
1035
  once(options) {
@@ -1075,8 +1123,13 @@ const stopInput = {
1075
1123
  on(handler) {
1076
1124
  return getChatSession().in.on((chunk) => {
1077
1125
  if (chunk.kind === "stop") {
1078
- return handler({ stop: true, message: chunk.message });
1126
+ // Consume stop records (see the messages facade above). A stop is
1127
+ // only meaningful to the turn it interrupts — buffering it would
1128
+ // let a stale stop abort a future turn.
1129
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1130
+ return true;
1079
1131
  }
1132
+ return undefined;
1080
1133
  });
1081
1134
  },
1082
1135
  once(options) {
@@ -1184,6 +1237,36 @@ const handoverInput = {
1184
1237
  }
1185
1238
  },
1186
1239
  };
1240
+ /**
1241
+ * Wait for a `chat.headStart` handover signal inside a custom-agent loop or
1242
+ * `chat.createSession`. Returns:
1243
+ * - `null` — this run is not a `handover-prepare` boot, or the wait idled out /
1244
+ * the warm handler crashed before signaling. Treat as "no handover".
1245
+ * - `{ kind: "handover-skip" }` — the warm handler aborted; exit without a turn.
1246
+ * - `{ kind: "handover", partialAssistantMessage, messageId?, isFinal }` — splice
1247
+ * the partial (`chat.MessageAccumulator.applyHandover`) and, when `isFinal` is
1248
+ * false, fall through to `streamText` to run the handed-over tool round.
1249
+ *
1250
+ * For the common case prefer `accumulator.consumeHandover()`, which also seeds
1251
+ * `payload.headStartMessages` and applies the partial for you.
1252
+ *
1253
+ * Must be called at turn 0 before any `chat.messages.waitWithIdleTimeout` —
1254
+ * that facade consumes and discards non-message chunks, which would swallow the
1255
+ * handover signal.
1256
+ */
1257
+ async function waitForHandover(options) {
1258
+ if (options.payload.trigger !== "handover-prepare")
1259
+ return null;
1260
+ const result = await handoverInput.waitWithIdleTimeout({
1261
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1262
+ timeout: options.timeout,
1263
+ spanName: options.spanName ?? "waiting for handover signal",
1264
+ });
1265
+ // Non-ok = idle timeout or the warm handler crashed without signaling.
1266
+ if (!result.ok)
1267
+ return null;
1268
+ return result.output;
1269
+ }
1187
1270
  /**
1188
1271
  * Per-turn deferred promises. Registered via `chat.defer()`, awaited
1189
1272
  * before `onTurnComplete` fires. Reset each turn.
@@ -1230,6 +1313,9 @@ const chatHandoverIsFinalKey = locals.create("chat.handoverIsFinal");
1230
1313
  * `tool-approval-response` rows are AI-SDK-internal and don't need a
1231
1314
  * UIMessage representation. We map:
1232
1315
  * - `text` parts → `{ type: "text", text }`
1316
+ * - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
1317
+ * (provider metadata carried so an Anthropic thinking signature
1318
+ * survives a UIMessage → ModelMessage round trip)
1233
1319
  * - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
1234
1320
  * state: "input-available", input }`
1235
1321
  * - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1246,6 +1332,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1246
1332
  if (part.type === "text" && typeof part.text === "string") {
1247
1333
  parts.push({ type: "text", text: part.text });
1248
1334
  }
1335
+ else if (part.type === "reasoning" && typeof part.text === "string") {
1336
+ parts.push({
1337
+ type: "reasoning",
1338
+ text: part.text,
1339
+ state: "done",
1340
+ ...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
1341
+ });
1342
+ }
1249
1343
  else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
1250
1344
  parts.push({
1251
1345
  type: `tool-${part.toolName}`,
@@ -1269,6 +1363,27 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1269
1363
  parts,
1270
1364
  };
1271
1365
  }
1366
+ /**
1367
+ * Splice a head-start handover partial into an accumulating message pair
1368
+ * (model + UI). Dedups by `messageId` against the UI chain (so a hydrated
1369
+ * history that already persisted the partial isn't doubled), then pushes the
1370
+ * partial into `modelMessages` and the synthesized UIMessage into `uiMessages`.
1371
+ * Shared by the `chat.agent` turn-0 splice and `ChatMessageAccumulator.applyHandover`.
1372
+ * @internal
1373
+ */
1374
+ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1375
+ if (!signal.partialAssistantMessage || signal.partialAssistantMessage.length === 0) {
1376
+ return;
1377
+ }
1378
+ // Skip if the hydrated chain already persisted the partial under this id.
1379
+ const alreadyInChain = signal.messageId !== undefined && uiMessages.some((m) => m.id === signal.messageId);
1380
+ if (alreadyInChain)
1381
+ return;
1382
+ modelMessages.push(...signal.partialAssistantMessage);
1383
+ const partialUI = synthesizeHandoverUIMessage(signal.partialAssistantMessage, signal.messageId);
1384
+ if (partialUI)
1385
+ uiMessages.push(partialUI);
1386
+ }
1272
1387
  /**
1273
1388
  * Per-turn background context queue. Messages added via `chat.backgroundWork.inject()`
1274
1389
  * are drained at the next `prepareStep` boundary and appended to the model messages.
@@ -2221,11 +2336,18 @@ function isCompactionSafe(messages) {
2221
2336
  }
2222
2337
  /** @internal */
2223
2338
  const chatPromptKey = locals.create("chat.prompt");
2339
+ /**
2340
+ * @internal Provider options attached to the system message that
2341
+ * `toStreamTextOptions()` builds from the stored prompt — lets a provider cache
2342
+ * the system block. Stored separately so it works for both the `ResolvedPrompt`
2343
+ * and plain-string forms without mutating the prompt object.
2344
+ */
2345
+ const chatPromptProviderOptionsKey = locals.create("chat.prompt.providerOptions");
2224
2346
  /**
2225
2347
  * Store a resolved prompt (or plain string) for the current run.
2226
2348
  * Call from any hook (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`.
2227
2349
  */
2228
- function setChatPrompt(resolved) {
2350
+ function setChatPrompt(resolved, options) {
2229
2351
  if (typeof resolved === "string") {
2230
2352
  locals.set(chatPromptKey, {
2231
2353
  text: resolved,
@@ -2242,6 +2364,9 @@ function setChatPrompt(resolved) {
2242
2364
  else {
2243
2365
  locals.set(chatPromptKey, resolved);
2244
2366
  }
2367
+ // Always overwrite the slot (even with undefined) so a later prompt.set with
2368
+ // no options clears a previous prompt's cache opt-in rather than leaking it.
2369
+ locals.set(chatPromptProviderOptionsKey, options?.providerOptions);
2245
2370
  }
2246
2371
  /**
2247
2372
  * Read the stored prompt. Throws if `chat.prompt.set()` has not been called.
@@ -2407,7 +2532,21 @@ function toStreamTextOptions(options) {
2407
2532
  const promptText = prompt?.text ?? "";
2408
2533
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
2409
2534
  if (promptText || skillsText) {
2410
- result.system = [promptText, skillsText].filter(Boolean).join("\n\n");
2535
+ const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
2536
+ // Resolve system-prompt provider options for caching. Precedence (most
2537
+ // specific wins, no deep merge): explicit `systemProviderOptions` →
2538
+ // `cacheControl` sugar → `providerOptions` stored on `chat.prompt.set()`.
2539
+ const systemProviderOptions = options?.systemProviderOptions ??
2540
+ (options?.cacheControl
2541
+ ? { anthropic: { cacheControl: options.cacheControl } }
2542
+ : undefined) ??
2543
+ locals.get(chatPromptProviderOptionsKey);
2544
+ // A bare string stays a bare string (the unchanged default). With provider
2545
+ // options, emit a structured `SystemModelMessage` so the provider can cache
2546
+ // the system block — `streamText`'s `system` accepts string | message.
2547
+ result.system = systemProviderOptions
2548
+ ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
2549
+ : systemText;
2411
2550
  }
2412
2551
  // Prompt-related options (only if chat.prompt.set() was called)
2413
2552
  if (prompt) {
@@ -2589,10 +2728,20 @@ function chatCustomAgent(options) {
2589
2728
  // `chat.createStartSessionAction`) before this run is triggered.
2590
2729
  // No client-side upsert needed.
2591
2730
  locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
2731
+ locals.set(chatExternalIdKey, payload.chatId);
2592
2732
  locals.set(chatAgentRunContextKey, runOptions.ctx);
2733
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2734
+ // trims `session.out` back to the previous turn boundary. Without
2735
+ // this the slot is undefined and the trim never runs, so `.out`
2736
+ // grows without bound for the whole custom-agent surface.
2737
+ locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
2593
2738
  markChatAgentRunForStreamsWarning();
2594
2739
  taskContext.setConversationId(payload.chatId);
2595
2740
  stampConversationIdOnActiveSpan(payload.chatId);
2741
+ // Seed the `.in` resume cursor before user code attaches any `.in`
2742
+ // listener — otherwise a continuation boot replays already-answered
2743
+ // messages into the loop's first wait.
2744
+ await seedSessionInResumeCursorForCustomLoop(payload);
2596
2745
  return userRun(payload, runOptions);
2597
2746
  },
2598
2747
  });
@@ -2639,6 +2788,7 @@ function chatAgent(options) {
2639
2788
  // `chat.createStartSessionAction` or browser-direct) before this
2640
2789
  // run is triggered — no client-side upsert needed here.
2641
2790
  locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
2791
+ locals.set(chatExternalIdKey, payload.chatId);
2642
2792
  // Mutable holder; advances in `writeTurnCompleteChunk` after each turn
2643
2793
  // and is the trim target for the NEXT turn's trim record.
2644
2794
  locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
@@ -2715,6 +2865,11 @@ function chatAgent(options) {
2715
2865
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2716
2866
  const bootInjectedQueue = [];
2717
2867
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2868
+ // `.in` resume cursor, computed at most once per boot. The boot
2869
+ // block below resolves it (snapshot field or records scan) and the
2870
+ // resume-cursor block reuses it instead of re-scanning.
2871
+ let bootInCursor;
2872
+ let bootInCursorResolved = false;
2718
2873
  if (!hydrateMessages && couldHavePriorState) {
2719
2874
  // Single parent span for the whole boot read phase — snapshot
2720
2875
  // read, session.out replay, session.in replay. Per-phase timing
@@ -2750,23 +2905,28 @@ function chatAgent(options) {
2750
2905
  slot.value = seeded;
2751
2906
  }
2752
2907
  }
2753
- // session.out replay
2754
- const replayOutStart = Date.now();
2755
- try {
2756
- const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2757
- replayedSettled = replayResult.settled;
2758
- replayedPartial = replayResult.partial;
2759
- replayedPartialRaw = replayResult.partialRaw;
2760
- }
2761
- catch (error) {
2762
- logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2763
- error: error instanceof Error ? error.message : String(error),
2764
- sessionId: sessionIdForSnapshot,
2765
- });
2766
- }
2767
- bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2768
- bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2769
- bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2908
+ // The `.out` replay and the `.in` cursor + tail read are
2909
+ // independent (both depend only on the snapshot) — run them
2910
+ // concurrently. Each phase keeps its own catch + duration
2911
+ // attribute.
2912
+ const replayOutPhase = async () => {
2913
+ const replayOutStart = Date.now();
2914
+ try {
2915
+ const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2916
+ replayedSettled = replayResult.settled;
2917
+ replayedPartial = replayResult.partial;
2918
+ replayedPartialRaw = replayResult.partialRaw;
2919
+ }
2920
+ catch (error) {
2921
+ logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2922
+ error: error instanceof Error ? error.message : String(error),
2923
+ sessionId: sessionIdForSnapshot,
2924
+ });
2925
+ }
2926
+ bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2927
+ bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2928
+ bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2929
+ };
2770
2930
  // session.in tail read
2771
2931
  //
2772
2932
  // session.in carries the user-side of the conversation
@@ -2777,20 +2937,43 @@ function chatAgent(options) {
2777
2937
  // visible via the live SSE subscription — by which point they
2778
2938
  // would arrive AFTER the partial-assistant orphan and look like
2779
2939
  // brand-new turns to the model, producing inverted chains.
2780
- const replayInStart = Date.now();
2781
- const lastInEventId = await findLatestSessionInCursor(payload.chatId)
2782
- .then((cursor) => (cursor !== undefined ? String(cursor) : undefined))
2783
- .catch(() => undefined);
2784
- try {
2785
- replayedInTail = await replaySessionInTail(payload.chatId, {
2786
- lastEventId: lastInEventId,
2787
- });
2788
- }
2789
- catch (error) {
2790
- logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2791
- }
2792
- bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2793
- bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2940
+ //
2941
+ // The cursor comes from the snapshot when present (written
2942
+ // there since `lastInEventId` was added) otherwise from a
2943
+ // records scan of `.out`'s latest turn-complete header.
2944
+ const replayInPhase = async () => {
2945
+ const replayInStart = Date.now();
2946
+ const snapshotInCursor = bootSnapshot?.lastInEventId !== undefined
2947
+ ? Number.parseInt(bootSnapshot.lastInEventId, 10)
2948
+ : undefined;
2949
+ if (snapshotInCursor !== undefined && Number.isFinite(snapshotInCursor)) {
2950
+ bootInCursor = snapshotInCursor;
2951
+ bootInCursorResolved = true;
2952
+ }
2953
+ else {
2954
+ try {
2955
+ bootInCursor = await findLatestSessionInCursor(payload.chatId);
2956
+ bootInCursorResolved = true;
2957
+ }
2958
+ catch {
2959
+ // Transient scan failure: leave unresolved so the
2960
+ // resume-cursor block below retries the lookup.
2961
+ bootInCursor = undefined;
2962
+ }
2963
+ }
2964
+ bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
2965
+ try {
2966
+ replayedInTail = await replaySessionInTail(payload.chatId, {
2967
+ lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
2968
+ });
2969
+ }
2970
+ catch (error) {
2971
+ logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2972
+ }
2973
+ bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2974
+ bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2975
+ };
2976
+ await Promise.all([replayOutPhase(), replayInPhase()]);
2794
2977
  }, {
2795
2978
  attributes: {
2796
2979
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
@@ -2831,7 +3014,12 @@ function chatAgent(options) {
2831
3014
  bootSnapshot !== undefined;
2832
3015
  if (needsResumeCursor) {
2833
3016
  try {
2834
- const cursor = await findLatestSessionInCursor(payload.chatId);
3017
+ // Reuse the cursor the boot block already resolved (snapshot
3018
+ // field or records scan) — only scan here when the boot block
3019
+ // was skipped (hydrateMessages, or snapshot-only signals).
3020
+ const cursor = bootInCursorResolved
3021
+ ? bootInCursor
3022
+ : await findLatestSessionInCursor(payload.chatId);
2835
3023
  if (cursor !== undefined) {
2836
3024
  sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
2837
3025
  sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
@@ -3595,6 +3783,18 @@ function chatAgent(options) {
3595
3783
  // therefore a delta merge, not a full-history reset.
3596
3784
  if (currentWirePayload.trigger !== "action") {
3597
3785
  let cleanedUIMessages = cleanedIncomingMessages;
3786
+ // Turn-0 head-start with hydrateMessages: the boot seeding from
3787
+ // `payload.headStartMessages` is non-hydrate-only, so ship the
3788
+ // route handler's first-turn history to the hydrate hook as
3789
+ // incoming messages instead (gated on the pending handover).
3790
+ if (turn === 0 &&
3791
+ hydrateMessages &&
3792
+ cleanedUIMessages.length === 0 &&
3793
+ (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
3794
+ Array.isArray(payload.headStartMessages) &&
3795
+ payload.headStartMessages.length > 0) {
3796
+ cleanedUIMessages = payload.headStartMessages;
3797
+ }
3598
3798
  // Validate/transform UIMessages before conversion — catches malformed
3599
3799
  // messages from storage or untrusted input before they reach the model.
3600
3800
  // Slim wire: triggers like `regenerate-message` carry no incoming
@@ -3773,40 +3973,39 @@ function chatAgent(options) {
3773
3973
  // `preload` / `close` / `handover-prepare` and submits
3774
3974
  // with no incoming message fall through with the boot-
3775
3975
  // seeded accumulator unchanged.
3776
- if (turn === 0) {
3777
- // Head-start handover splice (turn 0 only): the
3778
- // `chat.handover` route handler signalled a mid-turn
3779
- // handover, so splice its partial assistant response
3780
- // (text + pending tool-calls + the synthesized
3781
- // tool-approval round) onto the accumulator.
3782
- // `streamText` then hits AI SDK's initial-tool-
3783
- // execution branch, runs the agent-side tool executes,
3784
- // and resumes from step 2 — skipping the first model
3785
- // call (already done by the handler).
3786
- //
3787
- // We also synthesize a UIMessage form of the partial
3788
- // assistant and push it to `accumulatedUIMessages` so
3789
- // AI SDK's `processUIMessageStream` (invoked when the
3790
- // run loop calls `runResult.toUIMessageStream({
3791
- // onFinish })`) can initialize `state.message` from
3792
- // the trailing assistant in `originalMessages`. Without
3793
- // that, the `tool-output-available` chunks emitted by
3794
- // the initial-tool-execution branch can't find their
3795
- // matching tool-call in state and AI SDK throws
3796
- // `UIMessageStreamError: No tool invocation found`.
3797
- const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
3798
- if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3799
- accumulatedMessages.push(...pendingHandoverPartial);
3800
- const handoverMessageId = locals.get(chatHandoverMessageIdKey);
3801
- const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
3802
- if (partialUI) {
3803
- accumulatedUIMessages.push(partialUI);
3804
- }
3805
- locals.set(chatHandoverPartialKey, []); // consume once
3806
- }
3976
+ }
3977
+ if (turn === 0) {
3978
+ // Head-start handover splice (turn 0 only, BOTH
3979
+ // accumulation branches hydrate and default): the
3980
+ // `chat.handover` route handler signalled a mid-turn
3981
+ // handover, so splice its partial assistant response
3982
+ // (text + pending tool-calls + the synthesized
3983
+ // tool-approval round) onto the accumulator.
3984
+ // `streamText` then hits AI SDK's initial-tool-
3985
+ // execution branch, runs the agent-side tool executes,
3986
+ // and resumes from step 2 — skipping the first model
3987
+ // call (already done by the handler).
3988
+ //
3989
+ // We also synthesize a UIMessage form of the partial
3990
+ // assistant and push it to `accumulatedUIMessages` so
3991
+ // AI SDK's `processUIMessageStream` (invoked when the
3992
+ // run loop calls `runResult.toUIMessageStream({
3993
+ // onFinish })`) can initialize `state.message` from
3994
+ // the trailing assistant in `originalMessages`. Without
3995
+ // that, the `tool-output-available` chunks emitted by
3996
+ // the initial-tool-execution branch can't find their
3997
+ // matching tool-call in state and AI SDK throws
3998
+ // `UIMessageStreamError: No tool invocation found`.
3999
+ const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
4000
+ if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
4001
+ spliceHandoverPartial(accumulatedMessages, accumulatedUIMessages, {
4002
+ partialAssistantMessage: pendingHandoverPartial,
4003
+ messageId: locals.get(chatHandoverMessageIdKey),
4004
+ });
4005
+ locals.set(chatHandoverPartialKey, []); // consume once
3807
4006
  }
3808
- locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3809
4007
  }
4008
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3810
4009
  } // end if (trigger !== "action")
3811
4010
  // ── Action result handling ──────────────────────────────
3812
4011
  // For action turns, skip the turn machinery entirely.
@@ -4503,11 +4702,15 @@ function chatAgent(options) {
4503
4702
  if (!hydrateMessages) {
4504
4703
  try {
4505
4704
  await tracer.startActiveSpan("snapshot.write", async () => {
4705
+ const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4506
4706
  await writeChatSnapshot(sessionIdForSnapshot, {
4507
4707
  version: 1,
4508
4708
  savedAt: Date.now(),
4509
4709
  messages: accumulatedUIMessages,
4510
4710
  lastOutEventId: turnCompleteResult?.lastEventId,
4711
+ lastInEventId: snapshotInCursor !== undefined
4712
+ ? String(snapshotInCursor)
4713
+ : undefined,
4511
4714
  });
4512
4715
  }, {
4513
4716
  attributes: {
@@ -4644,17 +4847,100 @@ function chatAgent(options) {
4644
4847
  if (turnError instanceof OutOfMemoryError) {
4645
4848
  throw turnError;
4646
4849
  }
4850
+ let errorTurnCompleteResult;
4647
4851
  try {
4648
4852
  await withChatWriter(async (writer) => {
4649
4853
  const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
4650
4854
  writer.write({ type: "error", errorText });
4651
4855
  });
4652
4856
  // Signal turn complete so the client knows this turn is done
4653
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4857
+ errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
4654
4858
  }
4655
4859
  catch {
4656
4860
  // Best-effort — if stream write fails, let the run continue anyway
4657
4861
  }
4862
+ // The submit-message merge into the accumulator may not have run
4863
+ // yet (a pre-run hook threw), so fold the wire message in for the
4864
+ // error event + snapshot — the cursor has already advanced past it,
4865
+ // so otherwise it survives in neither the snapshot nor the `.in` tail.
4866
+ const erroredWireMessage = currentWirePayload.message;
4867
+ const erroredUIMessages = erroredWireMessage &&
4868
+ !accumulatedUIMessages.some((m) => m.id === erroredWireMessage.id)
4869
+ ? [...accumulatedUIMessages, erroredWireMessage]
4870
+ : accumulatedUIMessages;
4871
+ // Fire onTurnComplete on the error path too — the docs promise it
4872
+ // runs "after every turn, successful or errored" so customers can
4873
+ // mark the turn failed. `responseMessage` is undefined/partial and
4874
+ // `error` carries the thrown value.
4875
+ if (onTurnComplete) {
4876
+ try {
4877
+ await tracer.startActiveSpan("onTurnComplete()", async () => {
4878
+ await onTurnComplete({
4879
+ ctx,
4880
+ chatId: currentWirePayload.chatId,
4881
+ messages: accumulatedMessages,
4882
+ uiMessages: erroredUIMessages,
4883
+ newMessages: [],
4884
+ newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
4885
+ responseMessage: undefined,
4886
+ rawResponseMessage: undefined,
4887
+ turn,
4888
+ runId: ctx.run.id,
4889
+ chatAccessToken: "",
4890
+ // Parsed `clientData` isn't reliably in scope here (parsing
4891
+ // may itself be the failure), and the raw metadata is the
4892
+ // wrong shape — leave it undefined on the error path.
4893
+ clientData: undefined,
4894
+ stopped: false,
4895
+ continuation,
4896
+ previousRunId,
4897
+ preloaded,
4898
+ totalUsage: cumulativeUsage,
4899
+ finishReason: "error",
4900
+ error: turnError,
4901
+ lastEventId: errorTurnCompleteResult?.lastEventId,
4902
+ });
4903
+ }, {
4904
+ attributes: {
4905
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
4906
+ [SemanticInternalAttributes.COLLAPSED]: true,
4907
+ "chat.id": currentWirePayload.chatId,
4908
+ "chat.turn": turn + 1,
4909
+ "chat.errored": true,
4910
+ },
4911
+ });
4912
+ }
4913
+ catch {
4914
+ // A throwing onTurnComplete on the error path must not crash
4915
+ // the run — keep the conversation alive for the next message.
4916
+ }
4917
+ }
4918
+ // Persist a snapshot so the failed turn's user message isn't
4919
+ // stranded. `writeTurnCompleteChunk` already advanced the `.in`
4920
+ // cursor past it (via the session-in-event-id header), and the
4921
+ // success-path snapshot write is skipped on error — without this
4922
+ // the next boot would resume past a message that exists in
4923
+ // neither the snapshot nor the replayable `.in` tail.
4924
+ if (!hydrateMessages) {
4925
+ try {
4926
+ const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4927
+ await writeChatSnapshot(sessionIdForSnapshot, {
4928
+ version: 1,
4929
+ savedAt: Date.now(),
4930
+ messages: erroredUIMessages,
4931
+ lastOutEventId: errorTurnCompleteResult?.lastEventId,
4932
+ lastInEventId: errorSnapshotInCursor !== undefined
4933
+ ? String(errorSnapshotInCursor)
4934
+ : undefined,
4935
+ });
4936
+ }
4937
+ catch (error) {
4938
+ logger.warn("chat.agent: error-path snapshot write failed", {
4939
+ error: error instanceof Error ? error.message : String(error),
4940
+ sessionId: sessionIdForSnapshot,
4941
+ });
4942
+ }
4943
+ }
4658
4944
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
4659
4945
  if (locals.get(chatUpgradeRequestedKey) ||
4660
4946
  locals.get(chatEndRunRequestedKey)) {
@@ -5275,8 +5561,19 @@ async function pipeChatAndCapture(source, options) {
5275
5561
  const onFinishPromise = new Promise((r) => {
5276
5562
  resolveOnFinish = r;
5277
5563
  });
5564
+ const resolvedOptions = resolveUIMessageStreamOptions();
5278
5565
  const uiStream = source.toUIMessageStream({
5279
- ...resolveUIMessageStreamOptions(),
5566
+ ...resolvedOptions,
5567
+ // Thread the prior chain (incl. a spliced handover partial) so a resumed
5568
+ // tool round's tool-output chunks merge into the originating tool-call
5569
+ // instead of throwing "No tool invocation found".
5570
+ ...(options?.originalMessages ? { originalMessages: options.originalMessages } : {}),
5571
+ // Stamp a server-generated id on the start chunk, same as chat.agent's
5572
+ // pipe. Without it the AI SDK regenerates the assistant id when a
5573
+ // prepareStep injection (steering) starts a new step mid-stream, and
5574
+ // the frontend replaces the partial message — wiping the
5575
+ // pre-injection text from the UI and the captured response.
5576
+ generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
5280
5577
  onFinish: ({ responseMessage }) => {
5281
5578
  captured = responseMessage;
5282
5579
  resolveOnFinish();
@@ -5346,10 +5643,65 @@ class ChatMessageAccumulator {
5346
5643
  this.uiMessages = [...uiMessages];
5347
5644
  this.modelMessages = await toModelMessages(uiMessages);
5348
5645
  }
5646
+ /**
5647
+ * Splice a `chat.headStart` handover partial into the accumulator (the warm
5648
+ * step-1 response). Dedups by `messageId` so a seeded/hydrated history that
5649
+ * already carries the partial isn't doubled. Seed any prior history first
5650
+ * (e.g. `setMessages(payload.headStartMessages)`). Low-level — see
5651
+ * `consumeHandover` for the wait+seed+apply convenience.
5652
+ */
5653
+ applyHandover(signal) {
5654
+ spliceHandoverPartial(this.modelMessages, this.uiMessages, signal);
5655
+ }
5656
+ /**
5657
+ * One-call `chat.headStart` handover for a custom-agent loop: waits for the
5658
+ * handover signal, seeds prior history from `payload.headStartMessages`,
5659
+ * applies the warm step-1 partial, and reports what to do next.
5660
+ *
5661
+ * Returns `{ isFinal, skipped }`:
5662
+ * - `skipped: true` — not a `handover-prepare` run, the wait idled out, or the
5663
+ * warm handler aborted. Exit the run without a turn.
5664
+ * - `isFinal: true` — step 1 IS the response (pure text). Write turn-complete
5665
+ * and continue; do not call `streamText`.
5666
+ * - `isFinal: false` — fall through to `streamText`, which runs the pending
5667
+ * tool round handed over from step 1.
5668
+ */
5669
+ async consumeHandover(options) {
5670
+ const signal = await waitForHandover({
5671
+ payload: options.payload,
5672
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds,
5673
+ timeout: options.timeout,
5674
+ });
5675
+ if (!signal || signal.kind === "handover-skip") {
5676
+ return { isFinal: false, skipped: true };
5677
+ }
5678
+ if (options.payload.headStartMessages && options.payload.headStartMessages.length > 0) {
5679
+ await this.setMessages(options.payload.headStartMessages);
5680
+ }
5681
+ this.applyHandover(signal);
5682
+ return { isFinal: signal.isFinal, skipped: false };
5683
+ }
5349
5684
  async addResponse(response) {
5350
5685
  if (!response.id) {
5351
5686
  response = { ...response, id: generateMessageId() };
5352
5687
  }
5688
+ // Tool-approval and handover-resume continuations reuse the trailing
5689
+ // assistant's ID (via originalMessages on the pipe), so the captured
5690
+ // response can carry the same ID as a message already in the chain
5691
+ // (e.g. a spliced handover partial). Replace in place instead of pushing
5692
+ // a duplicate, mirroring the chat.agent accumulator.
5693
+ const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
5694
+ if (existingIdx !== -1) {
5695
+ this.uiMessages[existingIdx] = response;
5696
+ try {
5697
+ // Reconvert all model messages since we replaced rather than appended.
5698
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
5699
+ }
5700
+ catch {
5701
+ // Conversion failed — leave the existing model messages in place
5702
+ }
5703
+ return;
5704
+ }
5353
5705
  this.uiMessages.push(response);
5354
5706
  try {
5355
5707
  const msgs = await toModelMessages([stripProviderMetadata(response)]);
@@ -5482,14 +5834,18 @@ class ChatMessageAccumulator {
5482
5834
  * signaling, and idle/suspend between turns. You control: initialization,
5483
5835
  * model/tool selection, persistence, and any custom per-turn logic.
5484
5836
  *
5837
+ * Call from inside a `chat.customAgent()` run — the wrapper binds the
5838
+ * backing Session that the iterator's stop signal and message channels
5839
+ * resolve to. (A plain `task()` does not bind it, so `createSession`
5840
+ * would throw "session handle is not initialized".)
5841
+ *
5485
5842
  * @example
5486
5843
  * ```ts
5487
- * import { task } from "@trigger.dev/sdk";
5488
5844
  * import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
5489
5845
  * import { streamText } from "ai";
5490
5846
  * import { openai } from "@ai-sdk/openai";
5491
5847
  *
5492
- * export const myChat = task({
5848
+ * export const myChat = chat.customAgent({
5493
5849
  * id: "my-chat",
5494
5850
  * run: async (payload: ChatTaskWirePayload, { signal }) => {
5495
5851
  * const session = chat.createSession(payload, { signal });
@@ -5513,25 +5869,72 @@ function createChatSession(payload, options) {
5513
5869
  [Symbol.asyncIterator]() {
5514
5870
  let currentPayload = payload;
5515
5871
  let turn = -1;
5516
- const stop = createStopSignal();
5872
+ // Created on the first next() call, AFTER the resume-cursor seed —
5873
+ // createStopSignal attaches the `.in` SSE tail, and attaching
5874
+ // before the seed replays every record from seq 0 (the seed is a
5875
+ // no-op when the chatCustomAgent wrapper already ran it).
5876
+ let stop;
5877
+ let booted = false;
5517
5878
  const accumulator = new ChatMessageAccumulator();
5518
5879
  let previousTurnUsage;
5519
5880
  let cumulativeUsage = emptyUsage();
5520
5881
  return {
5521
5882
  async next() {
5883
+ if (!booted) {
5884
+ booted = true;
5885
+ await seedSessionInResumeCursorForCustomLoop(currentPayload);
5886
+ stop = createStopSignal();
5887
+ }
5522
5888
  turn++;
5523
- // First turn: handle preload wait for the first real message
5524
- if (turn === 0 && currentPayload.trigger === "preload") {
5889
+ // Head-start handover: the server triggered this run with
5890
+ // `trigger: "handover-prepare"` and signals the warm step-1 partial on
5891
+ // `session.in`. Wait for it BEFORE any `messagesInput.waitWithIdleTimeout`
5892
+ // (that facade consumes-and-discards non-message chunks and would swallow
5893
+ // the signal). Turn-0 only — continuation boots never carry this trigger.
5894
+ let handoverThisTurn = null;
5895
+ let pendingHandoverSignal = null;
5896
+ if (turn === 0 && currentPayload.trigger === "handover-prepare") {
5897
+ const signal = await waitForHandover({
5898
+ payload: currentPayload,
5899
+ idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? idleTimeoutInSeconds,
5900
+ timeout,
5901
+ });
5902
+ if (!signal || signal.kind === "handover-skip" || runSignal.aborted) {
5903
+ stop.cleanup();
5904
+ return { done: true, value: undefined };
5905
+ }
5906
+ pendingHandoverSignal = signal;
5907
+ handoverThisTurn = { isFinal: signal.isFinal };
5908
+ // Rewrite to a normal first-turn message turn so the rest of the loop
5909
+ // (steering setup, addIncoming, turnObj) runs unchanged.
5910
+ currentPayload = { ...currentPayload, trigger: "submit-message", message: undefined };
5911
+ }
5912
+ // First turn: wait when the boot payload carries no message.
5913
+ // Preload boots wait for the first real message; continuation
5914
+ // boots (fresh run via `ensureRunForSession` / end-and-continue)
5915
+ // arrive with the sticky boot-payload fields stripped, so running
5916
+ // a turn immediately would invoke the model with no user input.
5917
+ const isMessagelessContinuationBoot = currentPayload.continuation === true && !currentPayload.message;
5918
+ if (turn === 0 && (currentPayload.trigger === "preload" || isMessagelessContinuationBoot)) {
5525
5919
  const result = await messagesInput.waitWithIdleTimeout({
5526
5920
  idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
5527
5921
  timeout,
5528
- spanName: "waiting for first message",
5922
+ spanName: currentPayload.trigger === "preload"
5923
+ ? "waiting for first message"
5924
+ : "waiting for first message (continuation)",
5529
5925
  });
5530
5926
  if (!result.ok || runSignal.aborted) {
5531
5927
  stop.cleanup();
5532
5928
  return { done: true, value: undefined };
5533
5929
  }
5930
+ const continuationBoot = isMessagelessContinuationBoot;
5534
5931
  currentPayload = result.output;
5932
+ // Preserve the continuation flag — the wire payload of the next
5933
+ // message doesn't carry it, and `turn.continuation` is how the
5934
+ // user knows to seed history (e.g. `turn.setMessages(stored)`).
5935
+ if (continuationBoot && currentPayload.continuation === undefined) {
5936
+ currentPayload = { ...currentPayload, continuation: true };
5937
+ }
5535
5938
  }
5536
5939
  // Subsequent turns: wait for the next message
5537
5940
  if (turn > 0) {
@@ -5612,6 +6015,16 @@ function createChatSession(payload, options) {
5612
6015
  ? [currentPayload.message]
5613
6016
  : [];
5614
6017
  const messages = await accumulator.addIncoming(incomingForAccumulator, currentPayload.trigger, turn);
6018
+ // Apply the head-start handover AFTER addIncoming — turn-0 addIncoming
6019
+ // replaces accumulator state, which would wipe a pre-applied splice.
6020
+ // Seed prior history first, then splice the warm step-1 partial.
6021
+ if (pendingHandoverSignal) {
6022
+ const priorHistory = currentPayload.headStartMessages;
6023
+ if (priorHistory && priorHistory.length > 0) {
6024
+ await accumulator.setMessages(priorHistory);
6025
+ }
6026
+ accumulator.applyHandover(pendingHandoverSignal);
6027
+ }
5615
6028
  // chat.requestUpgrade() called before this turn — signal transport and exit
5616
6029
  if (locals.get(chatUpgradeRequestedKey)) {
5617
6030
  await writeUpgradeRequiredChunk();
@@ -5638,13 +6051,38 @@ function createChatSession(payload, options) {
5638
6051
  continuation: currentPayload.continuation ?? false,
5639
6052
  previousTurnUsage,
5640
6053
  totalUsage: cumulativeUsage,
6054
+ handover: handoverThisTurn,
5641
6055
  async setMessages(uiMessages) {
5642
6056
  await accumulator.setMessages(uiMessages);
5643
6057
  },
5644
6058
  async complete(source) {
6059
+ // Head-start final turn: the warm step-1 partial is already spliced
6060
+ // into the accumulator and IS the response — nothing to pipe. Only
6061
+ // valid on a final handover; a missing source on any other turn is a
6062
+ // mistake (it would silently finalize without an assistant response).
6063
+ if (!source) {
6064
+ if (!handoverThisTurn?.isFinal) {
6065
+ throw new Error("turn.complete() requires a stream source unless turn.handover.isFinal is true");
6066
+ }
6067
+ const response = accumulator.uiMessages.at(-1);
6068
+ if (!response || response.role !== "assistant") {
6069
+ throw new Error("turn.complete() could not find the spliced handover response");
6070
+ }
6071
+ sessionMsgSub.off();
6072
+ await chatWriteTurnComplete();
6073
+ return response;
6074
+ }
5645
6075
  let response;
5646
6076
  try {
5647
- response = await pipeChatAndCapture(source, { signal: combinedSignal });
6077
+ response = await pipeChatAndCapture(source, {
6078
+ signal: combinedSignal,
6079
+ // On a non-final handover turn, thread the spliced partial so a
6080
+ // resumed tool round's tool-output chunks merge into the
6081
+ // handed-over tool-call. Gated on the handover turn only — a
6082
+ // normal turn must not pass originalMessages (it would merge the
6083
+ // fresh response into the prior assistant message).
6084
+ ...(handoverThisTurn ? { originalMessages: accumulator.uiMessages } : {}),
6085
+ });
5648
6086
  }
5649
6087
  catch (error) {
5650
6088
  if (error instanceof Error && error.name === "AbortError") {
@@ -5684,14 +6122,22 @@ function createChatSession(payload, options) {
5684
6122
  locals.set(chatResponsePartsKey, []);
5685
6123
  }
5686
6124
  }
5687
- // Capture token usage from the streamText result
6125
+ // Capture token usage from the streamText result. Race with a 2s
6126
+ // timeout — on stop-abort the AI SDK's totalUsage promise can hang
6127
+ // indefinitely, which would wedge the turn loop (same guard as
6128
+ // chat.agent's turn loop).
5688
6129
  let turnUsage;
5689
6130
  if (typeof source.totalUsage?.then === "function") {
5690
6131
  try {
5691
- const usage = await source.totalUsage;
5692
- turnUsage = usage;
5693
- previousTurnUsage = usage;
5694
- cumulativeUsage = addUsage(cumulativeUsage, usage);
6132
+ const usage = (await Promise.race([
6133
+ source.totalUsage,
6134
+ new Promise((r) => setTimeout(() => r(undefined), 2_000)),
6135
+ ]));
6136
+ if (usage) {
6137
+ turnUsage = usage;
6138
+ previousTurnUsage = usage;
6139
+ cumulativeUsage = addUsage(cumulativeUsage, usage);
6140
+ }
5695
6141
  }
5696
6142
  catch {
5697
6143
  /* non-fatal */
@@ -5791,7 +6237,8 @@ function createChatSession(payload, options) {
5791
6237
  return { done: false, value: turnObj };
5792
6238
  },
5793
6239
  async return() {
5794
- stop.cleanup();
6240
+ // `stop` only exists once next() has booted the iterator.
6241
+ stop?.cleanup();
5795
6242
  return { done: true, value: undefined };
5796
6243
  },
5797
6244
  };
@@ -6034,6 +6481,7 @@ function createChatStartSessionAction(taskId, options) {
6034
6481
  // run-list filter by chat works without the customer having to wire it
6035
6482
  // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6036
6483
  const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6484
+ // SessionTriggerConfig.tags allows at most 5; the auto chat tag takes one slot.
6037
6485
  const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
6038
6486
  const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
6039
6487
  const triggerConfig = {
@@ -6058,6 +6506,20 @@ function createChatStartSessionAction(taskId, options) {
6058
6506
  maxAttempts: params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts,
6059
6507
  }
6060
6508
  : {}),
6509
+ ...(options?.triggerConfig?.maxDuration !== undefined ||
6510
+ params.triggerConfig?.maxDuration !== undefined
6511
+ ? {
6512
+ maxDuration: params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration,
6513
+ }
6514
+ : {}),
6515
+ ...(options?.triggerConfig?.region || params.triggerConfig?.region
6516
+ ? { region: params.triggerConfig?.region ?? options?.triggerConfig?.region }
6517
+ : {}),
6518
+ ...(options?.triggerConfig?.lockToVersion || params.triggerConfig?.lockToVersion
6519
+ ? {
6520
+ lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
6521
+ }
6522
+ : {}),
6061
6523
  ...(options?.triggerConfig?.idleTimeoutInSeconds !== undefined ||
6062
6524
  params.triggerConfig?.idleTimeoutInSeconds !== undefined
6063
6525
  ? {
@@ -6236,10 +6698,20 @@ export const chat = {
6236
6698
  MessageAccumulator: ChatMessageAccumulator,
6237
6699
  /** Create a chat session (async iterator). See {@link createChatSession}. */
6238
6700
  createSession: createChatSession,
6701
+ /**
6702
+ * Wait for a `chat.headStart` handover signal inside a `chat.customAgent`
6703
+ * loop (turn 0). See {@link waitForHandover}. For most loops prefer the
6704
+ * `chat.MessageAccumulator.consumeHandover()` convenience, which also seeds
6705
+ * `payload.headStartMessages` and applies the partial.
6706
+ */
6707
+ waitForHandover,
6239
6708
  /**
6240
6709
  * Store and retrieve a resolved prompt for the current run.
6241
6710
  *
6242
6711
  * - `chat.prompt.set(resolved)` — store a `ResolvedPrompt` or plain string
6712
+ * - `chat.prompt.set(resolved, { providerOptions })` — also attach provider
6713
+ * options to the system block so a provider can cache it (e.g. Anthropic
6714
+ * prompt caching). See the prompt-caching guide.
6243
6715
  * - `chat.prompt()` — read the stored prompt (throws if not set)
6244
6716
  */
6245
6717
  prompt: Object.assign(getChatPrompt, { set: setChatPrompt }),
@@ -6332,8 +6804,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
6332
6804
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
6333
6805
  // first-turn-ever (or first turn post-OOM without a snapshot seed) is
6334
6806
  // fine — the chain catches up next turn.
6335
- const slot = locals.get(lastTurnCompleteSeqNumKey);
6336
- const prev = slot?.value;
6807
+ //
6808
+ // Lazily create the slot if a caller reached here without one (a plain
6809
+ // `task()` driving `chat.createSession` / `chat.writeTurnComplete`, vs.
6810
+ // chatAgent/chatCustomAgent which seed it at boot). The first call then
6811
+ // does no trim (nothing before it) and records its seq; later calls trim
6812
+ // — so `.out` is bounded for every writeTurnComplete caller, not just the
6813
+ // built-in agents.
6814
+ let slot = locals.get(lastTurnCompleteSeqNumKey);
6815
+ if (!slot) {
6816
+ slot = { value: undefined };
6817
+ locals.set(lastTurnCompleteSeqNumKey, slot);
6818
+ }
6819
+ const prev = slot.value;
6337
6820
  if (slot && prev !== undefined) {
6338
6821
  try {
6339
6822
  await session.out.trimTo(prev);