@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
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.chat = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = void 0;
4
+ exports.__findLatestSessionInCursorForTests = __findLatestSessionInCursorForTests;
4
5
  exports.__setReadChatSnapshotImplForTests = __setReadChatSnapshotImplForTests;
5
6
  exports.__setWriteChatSnapshotImplForTests = __setWriteChatSnapshotImplForTests;
6
7
  exports.__readChatSnapshotProductionPathForTests = __readChatSnapshotProductionPathForTests;
@@ -62,6 +63,10 @@ const chatTurnContextKey = locals_js_1.locals.create("chat.turnContext");
62
63
  * @internal
63
64
  */
64
65
  const chatSessionHandleKey = locals_js_1.locals.create("chat.sessionHandle");
66
+ // The external `chatId` from the boot payload — the value `ToolCallExecutionOptions.chatId`
67
+ // is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
68
+ // metadata reads this directly rather than the Session handle id.
69
+ const chatExternalIdKey = locals_js_1.locals.create("chat.externalId");
65
70
  /**
66
71
  * S2 seq_num of the most recent `turn-complete` control record written by
67
72
  * this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -81,51 +86,78 @@ const lastTurnCompleteSeqNumKey = locals_js_1.locals.create("chat.lastTurnComple
81
86
  * the `.in` subscription so already-processed user messages don't get
82
87
  * replayed from S2.
83
88
  *
84
- * Implementation streams the SSE endpoint and listens for `turn-complete`
85
- * via the transport's `onControl` callback; the data-chunk for-await is
86
- * just there to drive the stream. The scan is O(1 turn) because
87
- * `session.out` is bounded to roughly one turn at steady state every
88
- * successful turn-complete is followed by an S2 trim back to the
89
- * previous one (see `writeTurnCompleteChunk`).
89
+ * Implementation is a non-blocking records read (`wait=0`) the
90
+ * endpoint returns everything currently stored (including pre-trim
91
+ * records, since S2 trims are eventually consistent) in one shot, and
92
+ * we keep the LAST matching header. The previous SSE-based scan had to
93
+ * idle-wait a full 5s window to know it reached the tail, which put a
94
+ * constant ~6s tax on every continuation boot.
90
95
  *
91
96
  * Returns `undefined` if no `turn-complete` carrying the header has been
92
97
  * written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
93
- * or a `turn-complete` written before this header existed (cross-version
94
- * boot). Callers fall back to subscribing `.in` from seq 0 in that case;
95
- * the slim-wire merge handles any dedup against snapshot-restored
96
- * messages.
98
+ * a `turn-complete` written before this header existed, or a server old
99
+ * enough that the records endpoint doesn't serialize headers. Callers
100
+ * fall back to subscribing `.in` from seq 0 in that case; the slim-wire
101
+ * merge handles any dedup against snapshot-restored messages.
97
102
  * @internal
98
103
  */
99
104
  async function findLatestSessionInCursor(chatId) {
100
105
  const apiClient = v3_1.apiClientManager.clientOrThrow();
106
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
101
107
  let latestCursor;
102
- const stream = await apiClient.subscribeToSessionStream(chatId, "out", {
103
- // 5s rather than 1s: S2 trim is eventually-consistent (10-60s
104
- // window), so a worker booting just after a trim could still see
105
- // pre-trim records and need a bit longer to drain them all before
106
- // the SSE long-poll closes. Without enough headroom the scan would
107
- // fall back to `undefined`, the `.in` cursor wouldn't be seeded,
108
- // and the next subscribe would replay messages already processed.
109
- timeoutInSeconds: 5,
110
- onControl: (event) => {
111
- if (event.subtype !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
112
- return;
113
- const raw = (0, v3_1.headerValue)(event.headers, v3_1.SESSION_IN_EVENT_ID_HEADER);
114
- if (!raw)
115
- return;
116
- const parsed = Number.parseInt(raw, 10);
117
- if (Number.isFinite(parsed))
118
- latestCursor = parsed;
119
- },
120
- });
121
- // Drain the stream so the underlying SSE reader runs to completion. We
122
- // don't accumulate chunks; `onControl` fires inline as turn-complete
123
- // records arrive.
124
- for await (const _ of stream) {
125
- // intentionally empty
108
+ for (const record of response.records) {
109
+ if ((0, v3_1.controlSubtype)(record.headers) !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
110
+ continue;
111
+ const raw = (0, v3_1.headerValue)(record.headers, v3_1.SESSION_IN_EVENT_ID_HEADER);
112
+ if (!raw)
113
+ continue;
114
+ const parsed = Number.parseInt(raw, 10);
115
+ if (Number.isFinite(parsed))
116
+ latestCursor = parsed;
126
117
  }
127
118
  return latestCursor;
128
119
  }
120
+ /** Test-only entry point for the records-based cursor scan. @internal */
121
+ async function __findLatestSessionInCursorForTests(chatId) {
122
+ return findLatestSessionInCursor(chatId);
123
+ }
124
+ /**
125
+ * Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent`
126
+ * raw loops and `chat.createSession`) the way `chat.agent`'s boot does.
127
+ *
128
+ * MUST run before anything attaches a `.in` listener (`createStopSignal`,
129
+ * `chat.messages.on`, the first wait): attaching opens the SSE tail with
130
+ * `Last-Event-ID` from the seeded cursor, so attach-then-seed replays
131
+ * every record from seq 0 — already-answered user messages get delivered
132
+ * into the new run's first wait and the loop re-answers them.
133
+ *
134
+ * Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`,
135
+ * `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the
136
+ * former still re-delivers records the manager buffered before the seed.
137
+ *
138
+ * No-ops on fresh boots and when a cursor is already seeded (e.g. the
139
+ * `chatCustomAgent` wrapper ran before a nested `createChatSession`).
140
+ * @internal
141
+ */
142
+ async function seedSessionInResumeCursorForCustomLoop(payload) {
143
+ if (v3_1.sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined)
144
+ return;
145
+ // No continuation/attempt gate: the wire may omit `continuation` on a
146
+ // run that still has prior turns (chat.agent covers that case via its
147
+ // snapshot). The scan doubles as the prior-state probe — a fresh
148
+ // session has no turn-complete on `.out`, returns no cursor, and
149
+ // seeds nothing. Cost on fresh boots is one non-blocking records read.
150
+ try {
151
+ const cursor = await findLatestSessionInCursor(payload.chatId);
152
+ if (cursor !== undefined) {
153
+ v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
154
+ v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
155
+ }
156
+ }
157
+ catch (error) {
158
+ v3_1.logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", { error: error instanceof Error ? error.message : String(error) });
159
+ }
160
+ }
129
161
  let readChatSnapshotImpl;
130
162
  function __setReadChatSnapshotImplForTests(impl) {
131
163
  readChatSnapshotImpl = impl;
@@ -677,6 +709,16 @@ function createTaskToolExecuteHandler(task) {
677
709
  toolMeta.continuation = chatCtx.continuation;
678
710
  toolMeta.clientData = chatCtx.clientData;
679
711
  }
712
+ else {
713
+ // Hand-rolled chat.customAgent loops never set per-turn context, but
714
+ // the wrapper records the boot payload's external chatId at run boot
715
+ // — thread it so subtask chat helpers (`chat.stream.writer` with
716
+ // target "root") can open the parent's session.
717
+ const chatExternalId = locals_js_1.locals.get(chatExternalIdKey);
718
+ if (chatExternalId) {
719
+ toolMeta.chatId = chatExternalId;
720
+ }
721
+ }
680
722
  const chatLocals = {};
681
723
  for (const entry of chatLocalRegistry) {
682
724
  const value = locals_js_1.locals.get(entry.key);
@@ -993,8 +1035,15 @@ const messagesInput = {
993
1035
  on(handler) {
994
1036
  return getChatSession().in.on((chunk) => {
995
1037
  if (chunk.kind === "message") {
996
- return handler(chunk.payload);
1038
+ // Returning `true` marks the record CONSUMED at the manager level:
1039
+ // it is neither buffered for a later `once()` nor re-delivered by
1040
+ // the buffer drain when the next turn re-attaches its handler.
1041
+ // Without this, a message arriving mid-stream was delivered twice
1042
+ // and ran a duplicate turn.
1043
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
1044
+ return true;
997
1045
  }
1046
+ return undefined;
998
1047
  });
999
1048
  },
1000
1049
  once(options) {
@@ -1088,8 +1137,13 @@ const stopInput = {
1088
1137
  on(handler) {
1089
1138
  return getChatSession().in.on((chunk) => {
1090
1139
  if (chunk.kind === "stop") {
1091
- return handler({ stop: true, message: chunk.message });
1140
+ // Consume stop records (see the messages facade above). A stop is
1141
+ // only meaningful to the turn it interrupts — buffering it would
1142
+ // let a stale stop abort a future turn.
1143
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1144
+ return true;
1092
1145
  }
1146
+ return undefined;
1093
1147
  });
1094
1148
  },
1095
1149
  once(options) {
@@ -1197,6 +1251,36 @@ const handoverInput = {
1197
1251
  }
1198
1252
  },
1199
1253
  };
1254
+ /**
1255
+ * Wait for a `chat.headStart` handover signal inside a custom-agent loop or
1256
+ * `chat.createSession`. Returns:
1257
+ * - `null` — this run is not a `handover-prepare` boot, or the wait idled out /
1258
+ * the warm handler crashed before signaling. Treat as "no handover".
1259
+ * - `{ kind: "handover-skip" }` — the warm handler aborted; exit without a turn.
1260
+ * - `{ kind: "handover", partialAssistantMessage, messageId?, isFinal }` — splice
1261
+ * the partial (`chat.MessageAccumulator.applyHandover`) and, when `isFinal` is
1262
+ * false, fall through to `streamText` to run the handed-over tool round.
1263
+ *
1264
+ * For the common case prefer `accumulator.consumeHandover()`, which also seeds
1265
+ * `payload.headStartMessages` and applies the partial for you.
1266
+ *
1267
+ * Must be called at turn 0 before any `chat.messages.waitWithIdleTimeout` —
1268
+ * that facade consumes and discards non-message chunks, which would swallow the
1269
+ * handover signal.
1270
+ */
1271
+ async function waitForHandover(options) {
1272
+ if (options.payload.trigger !== "handover-prepare")
1273
+ return null;
1274
+ const result = await handoverInput.waitWithIdleTimeout({
1275
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
1276
+ timeout: options.timeout,
1277
+ spanName: options.spanName ?? "waiting for handover signal",
1278
+ });
1279
+ // Non-ok = idle timeout or the warm handler crashed without signaling.
1280
+ if (!result.ok)
1281
+ return null;
1282
+ return result.output;
1283
+ }
1200
1284
  /**
1201
1285
  * Per-turn deferred promises. Registered via `chat.defer()`, awaited
1202
1286
  * before `onTurnComplete` fires. Reset each turn.
@@ -1243,6 +1327,9 @@ const chatHandoverIsFinalKey = locals_js_1.locals.create("chat.handoverIsFinal")
1243
1327
  * `tool-approval-response` rows are AI-SDK-internal and don't need a
1244
1328
  * UIMessage representation. We map:
1245
1329
  * - `text` parts → `{ type: "text", text }`
1330
+ * - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
1331
+ * (provider metadata carried so an Anthropic thinking signature
1332
+ * survives a UIMessage → ModelMessage round trip)
1246
1333
  * - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
1247
1334
  * state: "input-available", input }`
1248
1335
  * - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1259,6 +1346,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1259
1346
  if (part.type === "text" && typeof part.text === "string") {
1260
1347
  parts.push({ type: "text", text: part.text });
1261
1348
  }
1349
+ else if (part.type === "reasoning" && typeof part.text === "string") {
1350
+ parts.push({
1351
+ type: "reasoning",
1352
+ text: part.text,
1353
+ state: "done",
1354
+ ...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
1355
+ });
1356
+ }
1262
1357
  else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
1263
1358
  parts.push({
1264
1359
  type: `tool-${part.toolName}`,
@@ -1282,6 +1377,27 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1282
1377
  parts,
1283
1378
  };
1284
1379
  }
1380
+ /**
1381
+ * Splice a head-start handover partial into an accumulating message pair
1382
+ * (model + UI). Dedups by `messageId` against the UI chain (so a hydrated
1383
+ * history that already persisted the partial isn't doubled), then pushes the
1384
+ * partial into `modelMessages` and the synthesized UIMessage into `uiMessages`.
1385
+ * Shared by the `chat.agent` turn-0 splice and `ChatMessageAccumulator.applyHandover`.
1386
+ * @internal
1387
+ */
1388
+ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1389
+ if (!signal.partialAssistantMessage || signal.partialAssistantMessage.length === 0) {
1390
+ return;
1391
+ }
1392
+ // Skip if the hydrated chain already persisted the partial under this id.
1393
+ const alreadyInChain = signal.messageId !== undefined && uiMessages.some((m) => m.id === signal.messageId);
1394
+ if (alreadyInChain)
1395
+ return;
1396
+ modelMessages.push(...signal.partialAssistantMessage);
1397
+ const partialUI = synthesizeHandoverUIMessage(signal.partialAssistantMessage, signal.messageId);
1398
+ if (partialUI)
1399
+ uiMessages.push(partialUI);
1400
+ }
1285
1401
  /**
1286
1402
  * Per-turn background context queue. Messages added via `chat.backgroundWork.inject()`
1287
1403
  * are drained at the next `prepareStep` boundary and appended to the model messages.
@@ -2236,11 +2352,18 @@ function isCompactionSafe(messages) {
2236
2352
  }
2237
2353
  /** @internal */
2238
2354
  const chatPromptKey = locals_js_1.locals.create("chat.prompt");
2355
+ /**
2356
+ * @internal Provider options attached to the system message that
2357
+ * `toStreamTextOptions()` builds from the stored prompt — lets a provider cache
2358
+ * the system block. Stored separately so it works for both the `ResolvedPrompt`
2359
+ * and plain-string forms without mutating the prompt object.
2360
+ */
2361
+ const chatPromptProviderOptionsKey = locals_js_1.locals.create("chat.prompt.providerOptions");
2239
2362
  /**
2240
2363
  * Store a resolved prompt (or plain string) for the current run.
2241
2364
  * Call from any hook (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`.
2242
2365
  */
2243
- function setChatPrompt(resolved) {
2366
+ function setChatPrompt(resolved, options) {
2244
2367
  if (typeof resolved === "string") {
2245
2368
  locals_js_1.locals.set(chatPromptKey, {
2246
2369
  text: resolved,
@@ -2257,6 +2380,9 @@ function setChatPrompt(resolved) {
2257
2380
  else {
2258
2381
  locals_js_1.locals.set(chatPromptKey, resolved);
2259
2382
  }
2383
+ // Always overwrite the slot (even with undefined) so a later prompt.set with
2384
+ // no options clears a previous prompt's cache opt-in rather than leaking it.
2385
+ locals_js_1.locals.set(chatPromptProviderOptionsKey, options?.providerOptions);
2260
2386
  }
2261
2387
  /**
2262
2388
  * Read the stored prompt. Throws if `chat.prompt.set()` has not been called.
@@ -2422,7 +2548,21 @@ function toStreamTextOptions(options) {
2422
2548
  const promptText = prompt?.text ?? "";
2423
2549
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
2424
2550
  if (promptText || skillsText) {
2425
- result.system = [promptText, skillsText].filter(Boolean).join("\n\n");
2551
+ const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
2552
+ // Resolve system-prompt provider options for caching. Precedence (most
2553
+ // specific wins, no deep merge): explicit `systemProviderOptions` →
2554
+ // `cacheControl` sugar → `providerOptions` stored on `chat.prompt.set()`.
2555
+ const systemProviderOptions = options?.systemProviderOptions ??
2556
+ (options?.cacheControl
2557
+ ? { anthropic: { cacheControl: options.cacheControl } }
2558
+ : undefined) ??
2559
+ locals_js_1.locals.get(chatPromptProviderOptionsKey);
2560
+ // A bare string stays a bare string (the unchanged default). With provider
2561
+ // options, emit a structured `SystemModelMessage` so the provider can cache
2562
+ // the system block — `streamText`'s `system` accepts string | message.
2563
+ result.system = systemProviderOptions
2564
+ ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
2565
+ : systemText;
2426
2566
  }
2427
2567
  // Prompt-related options (only if chat.prompt.set() was called)
2428
2568
  if (prompt) {
@@ -2604,10 +2744,20 @@ function chatCustomAgent(options) {
2604
2744
  // `chat.createStartSessionAction`) before this run is triggered.
2605
2745
  // No client-side upsert needed.
2606
2746
  locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
2747
+ locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
2607
2748
  locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
2749
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2750
+ // trims `session.out` back to the previous turn boundary. Without
2751
+ // this the slot is undefined and the trim never runs, so `.out`
2752
+ // grows without bound for the whole custom-agent surface.
2753
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
2608
2754
  (0, streams_js_1.markChatAgentRunForStreamsWarning)();
2609
2755
  v3_1.taskContext.setConversationId(payload.chatId);
2610
2756
  stampConversationIdOnActiveSpan(payload.chatId);
2757
+ // Seed the `.in` resume cursor before user code attaches any `.in`
2758
+ // listener — otherwise a continuation boot replays already-answered
2759
+ // messages into the loop's first wait.
2760
+ await seedSessionInResumeCursorForCustomLoop(payload);
2611
2761
  return userRun(payload, runOptions);
2612
2762
  },
2613
2763
  });
@@ -2654,6 +2804,7 @@ function chatAgent(options) {
2654
2804
  // `chat.createStartSessionAction` or browser-direct) before this
2655
2805
  // run is triggered — no client-side upsert needed here.
2656
2806
  locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
2807
+ locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
2657
2808
  // Mutable holder; advances in `writeTurnCompleteChunk` after each turn
2658
2809
  // and is the trim target for the NEXT turn's trim record.
2659
2810
  locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
@@ -2730,6 +2881,11 @@ function chatAgent(options) {
2730
2881
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2731
2882
  const bootInjectedQueue = [];
2732
2883
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2884
+ // `.in` resume cursor, computed at most once per boot. The boot
2885
+ // block below resolves it (snapshot field or records scan) and the
2886
+ // resume-cursor block reuses it instead of re-scanning.
2887
+ let bootInCursor;
2888
+ let bootInCursorResolved = false;
2733
2889
  if (!hydrateMessages && couldHavePriorState) {
2734
2890
  // Single parent span for the whole boot read phase — snapshot
2735
2891
  // read, session.out replay, session.in replay. Per-phase timing
@@ -2765,23 +2921,28 @@ function chatAgent(options) {
2765
2921
  slot.value = seeded;
2766
2922
  }
2767
2923
  }
2768
- // session.out replay
2769
- const replayOutStart = Date.now();
2770
- try {
2771
- const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2772
- replayedSettled = replayResult.settled;
2773
- replayedPartial = replayResult.partial;
2774
- replayedPartialRaw = replayResult.partialRaw;
2775
- }
2776
- catch (error) {
2777
- v3_1.logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2778
- error: error instanceof Error ? error.message : String(error),
2779
- sessionId: sessionIdForSnapshot,
2780
- });
2781
- }
2782
- bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2783
- bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2784
- bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2924
+ // The `.out` replay and the `.in` cursor + tail read are
2925
+ // independent (both depend only on the snapshot) — run them
2926
+ // concurrently. Each phase keeps its own catch + duration
2927
+ // attribute.
2928
+ const replayOutPhase = async () => {
2929
+ const replayOutStart = Date.now();
2930
+ try {
2931
+ const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2932
+ replayedSettled = replayResult.settled;
2933
+ replayedPartial = replayResult.partial;
2934
+ replayedPartialRaw = replayResult.partialRaw;
2935
+ }
2936
+ catch (error) {
2937
+ v3_1.logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2938
+ error: error instanceof Error ? error.message : String(error),
2939
+ sessionId: sessionIdForSnapshot,
2940
+ });
2941
+ }
2942
+ bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2943
+ bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2944
+ bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2945
+ };
2785
2946
  // session.in tail read
2786
2947
  //
2787
2948
  // session.in carries the user-side of the conversation
@@ -2792,20 +2953,43 @@ function chatAgent(options) {
2792
2953
  // visible via the live SSE subscription — by which point they
2793
2954
  // would arrive AFTER the partial-assistant orphan and look like
2794
2955
  // brand-new turns to the model, producing inverted chains.
2795
- const replayInStart = Date.now();
2796
- const lastInEventId = await findLatestSessionInCursor(payload.chatId)
2797
- .then((cursor) => (cursor !== undefined ? String(cursor) : undefined))
2798
- .catch(() => undefined);
2799
- try {
2800
- replayedInTail = await replaySessionInTail(payload.chatId, {
2801
- lastEventId: lastInEventId,
2802
- });
2803
- }
2804
- catch (error) {
2805
- v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2806
- }
2807
- bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2808
- bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2956
+ //
2957
+ // The cursor comes from the snapshot when present (written
2958
+ // there since `lastInEventId` was added) otherwise from a
2959
+ // records scan of `.out`'s latest turn-complete header.
2960
+ const replayInPhase = async () => {
2961
+ const replayInStart = Date.now();
2962
+ const snapshotInCursor = bootSnapshot?.lastInEventId !== undefined
2963
+ ? Number.parseInt(bootSnapshot.lastInEventId, 10)
2964
+ : undefined;
2965
+ if (snapshotInCursor !== undefined && Number.isFinite(snapshotInCursor)) {
2966
+ bootInCursor = snapshotInCursor;
2967
+ bootInCursorResolved = true;
2968
+ }
2969
+ else {
2970
+ try {
2971
+ bootInCursor = await findLatestSessionInCursor(payload.chatId);
2972
+ bootInCursorResolved = true;
2973
+ }
2974
+ catch {
2975
+ // Transient scan failure: leave unresolved so the
2976
+ // resume-cursor block below retries the lookup.
2977
+ bootInCursor = undefined;
2978
+ }
2979
+ }
2980
+ bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
2981
+ try {
2982
+ replayedInTail = await replaySessionInTail(payload.chatId, {
2983
+ lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
2984
+ });
2985
+ }
2986
+ catch (error) {
2987
+ v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2988
+ }
2989
+ bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2990
+ bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2991
+ };
2992
+ await Promise.all([replayOutPhase(), replayInPhase()]);
2809
2993
  }, {
2810
2994
  attributes: {
2811
2995
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
@@ -2846,7 +3030,12 @@ function chatAgent(options) {
2846
3030
  bootSnapshot !== undefined;
2847
3031
  if (needsResumeCursor) {
2848
3032
  try {
2849
- const cursor = await findLatestSessionInCursor(payload.chatId);
3033
+ // Reuse the cursor the boot block already resolved (snapshot
3034
+ // field or records scan) — only scan here when the boot block
3035
+ // was skipped (hydrateMessages, or snapshot-only signals).
3036
+ const cursor = bootInCursorResolved
3037
+ ? bootInCursor
3038
+ : await findLatestSessionInCursor(payload.chatId);
2850
3039
  if (cursor !== undefined) {
2851
3040
  v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
2852
3041
  v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
@@ -3610,6 +3799,18 @@ function chatAgent(options) {
3610
3799
  // therefore a delta merge, not a full-history reset.
3611
3800
  if (currentWirePayload.trigger !== "action") {
3612
3801
  let cleanedUIMessages = cleanedIncomingMessages;
3802
+ // Turn-0 head-start with hydrateMessages: the boot seeding from
3803
+ // `payload.headStartMessages` is non-hydrate-only, so ship the
3804
+ // route handler's first-turn history to the hydrate hook as
3805
+ // incoming messages instead (gated on the pending handover).
3806
+ if (turn === 0 &&
3807
+ hydrateMessages &&
3808
+ cleanedUIMessages.length === 0 &&
3809
+ (locals_js_1.locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
3810
+ Array.isArray(payload.headStartMessages) &&
3811
+ payload.headStartMessages.length > 0) {
3812
+ cleanedUIMessages = payload.headStartMessages;
3813
+ }
3613
3814
  // Validate/transform UIMessages before conversion — catches malformed
3614
3815
  // messages from storage or untrusted input before they reach the model.
3615
3816
  // Slim wire: triggers like `regenerate-message` carry no incoming
@@ -3788,40 +3989,39 @@ function chatAgent(options) {
3788
3989
  // `preload` / `close` / `handover-prepare` and submits
3789
3990
  // with no incoming message fall through with the boot-
3790
3991
  // seeded accumulator unchanged.
3791
- if (turn === 0) {
3792
- // Head-start handover splice (turn 0 only): the
3793
- // `chat.handover` route handler signalled a mid-turn
3794
- // handover, so splice its partial assistant response
3795
- // (text + pending tool-calls + the synthesized
3796
- // tool-approval round) onto the accumulator.
3797
- // `streamText` then hits AI SDK's initial-tool-
3798
- // execution branch, runs the agent-side tool executes,
3799
- // and resumes from step 2 — skipping the first model
3800
- // call (already done by the handler).
3801
- //
3802
- // We also synthesize a UIMessage form of the partial
3803
- // assistant and push it to `accumulatedUIMessages` so
3804
- // AI SDK's `processUIMessageStream` (invoked when the
3805
- // run loop calls `runResult.toUIMessageStream({
3806
- // onFinish })`) can initialize `state.message` from
3807
- // the trailing assistant in `originalMessages`. Without
3808
- // that, the `tool-output-available` chunks emitted by
3809
- // the initial-tool-execution branch can't find their
3810
- // matching tool-call in state and AI SDK throws
3811
- // `UIMessageStreamError: No tool invocation found`.
3812
- const pendingHandoverPartial = locals_js_1.locals.get(chatHandoverPartialKey);
3813
- if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3814
- accumulatedMessages.push(...pendingHandoverPartial);
3815
- const handoverMessageId = locals_js_1.locals.get(chatHandoverMessageIdKey);
3816
- const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
3817
- if (partialUI) {
3818
- accumulatedUIMessages.push(partialUI);
3819
- }
3820
- locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
3821
- }
3992
+ }
3993
+ if (turn === 0) {
3994
+ // Head-start handover splice (turn 0 only, BOTH
3995
+ // accumulation branches hydrate and default): the
3996
+ // `chat.handover` route handler signalled a mid-turn
3997
+ // handover, so splice its partial assistant response
3998
+ // (text + pending tool-calls + the synthesized
3999
+ // tool-approval round) onto the accumulator.
4000
+ // `streamText` then hits AI SDK's initial-tool-
4001
+ // execution branch, runs the agent-side tool executes,
4002
+ // and resumes from step 2 — skipping the first model
4003
+ // call (already done by the handler).
4004
+ //
4005
+ // We also synthesize a UIMessage form of the partial
4006
+ // assistant and push it to `accumulatedUIMessages` so
4007
+ // AI SDK's `processUIMessageStream` (invoked when the
4008
+ // run loop calls `runResult.toUIMessageStream({
4009
+ // onFinish })`) can initialize `state.message` from
4010
+ // the trailing assistant in `originalMessages`. Without
4011
+ // that, the `tool-output-available` chunks emitted by
4012
+ // the initial-tool-execution branch can't find their
4013
+ // matching tool-call in state and AI SDK throws
4014
+ // `UIMessageStreamError: No tool invocation found`.
4015
+ const pendingHandoverPartial = locals_js_1.locals.get(chatHandoverPartialKey);
4016
+ if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
4017
+ spliceHandoverPartial(accumulatedMessages, accumulatedUIMessages, {
4018
+ partialAssistantMessage: pendingHandoverPartial,
4019
+ messageId: locals_js_1.locals.get(chatHandoverMessageIdKey),
4020
+ });
4021
+ locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
3822
4022
  }
3823
- locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3824
4023
  }
4024
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3825
4025
  } // end if (trigger !== "action")
3826
4026
  // ── Action result handling ──────────────────────────────
3827
4027
  // For action turns, skip the turn machinery entirely.
@@ -4518,11 +4718,15 @@ function chatAgent(options) {
4518
4718
  if (!hydrateMessages) {
4519
4719
  try {
4520
4720
  await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
4721
+ const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4521
4722
  await writeChatSnapshot(sessionIdForSnapshot, {
4522
4723
  version: 1,
4523
4724
  savedAt: Date.now(),
4524
4725
  messages: accumulatedUIMessages,
4525
4726
  lastOutEventId: turnCompleteResult?.lastEventId,
4727
+ lastInEventId: snapshotInCursor !== undefined
4728
+ ? String(snapshotInCursor)
4729
+ : undefined,
4526
4730
  });
4527
4731
  }, {
4528
4732
  attributes: {
@@ -4659,17 +4863,100 @@ function chatAgent(options) {
4659
4863
  if (turnError instanceof v3_1.OutOfMemoryError) {
4660
4864
  throw turnError;
4661
4865
  }
4866
+ let errorTurnCompleteResult;
4662
4867
  try {
4663
4868
  await withChatWriter(async (writer) => {
4664
4869
  const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
4665
4870
  writer.write({ type: "error", errorText });
4666
4871
  });
4667
4872
  // Signal turn complete so the client knows this turn is done
4668
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4873
+ errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
4669
4874
  }
4670
4875
  catch {
4671
4876
  // Best-effort — if stream write fails, let the run continue anyway
4672
4877
  }
4878
+ // The submit-message merge into the accumulator may not have run
4879
+ // yet (a pre-run hook threw), so fold the wire message in for the
4880
+ // error event + snapshot — the cursor has already advanced past it,
4881
+ // so otherwise it survives in neither the snapshot nor the `.in` tail.
4882
+ const erroredWireMessage = currentWirePayload.message;
4883
+ const erroredUIMessages = erroredWireMessage &&
4884
+ !accumulatedUIMessages.some((m) => m.id === erroredWireMessage.id)
4885
+ ? [...accumulatedUIMessages, erroredWireMessage]
4886
+ : accumulatedUIMessages;
4887
+ // Fire onTurnComplete on the error path too — the docs promise it
4888
+ // runs "after every turn, successful or errored" so customers can
4889
+ // mark the turn failed. `responseMessage` is undefined/partial and
4890
+ // `error` carries the thrown value.
4891
+ if (onTurnComplete) {
4892
+ try {
4893
+ await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
4894
+ await onTurnComplete({
4895
+ ctx,
4896
+ chatId: currentWirePayload.chatId,
4897
+ messages: accumulatedMessages,
4898
+ uiMessages: erroredUIMessages,
4899
+ newMessages: [],
4900
+ newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
4901
+ responseMessage: undefined,
4902
+ rawResponseMessage: undefined,
4903
+ turn,
4904
+ runId: ctx.run.id,
4905
+ chatAccessToken: "",
4906
+ // Parsed `clientData` isn't reliably in scope here (parsing
4907
+ // may itself be the failure), and the raw metadata is the
4908
+ // wrong shape — leave it undefined on the error path.
4909
+ clientData: undefined,
4910
+ stopped: false,
4911
+ continuation,
4912
+ previousRunId,
4913
+ preloaded,
4914
+ totalUsage: cumulativeUsage,
4915
+ finishReason: "error",
4916
+ error: turnError,
4917
+ lastEventId: errorTurnCompleteResult?.lastEventId,
4918
+ });
4919
+ }, {
4920
+ attributes: {
4921
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
4922
+ [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
4923
+ "chat.id": currentWirePayload.chatId,
4924
+ "chat.turn": turn + 1,
4925
+ "chat.errored": true,
4926
+ },
4927
+ });
4928
+ }
4929
+ catch {
4930
+ // A throwing onTurnComplete on the error path must not crash
4931
+ // the run — keep the conversation alive for the next message.
4932
+ }
4933
+ }
4934
+ // Persist a snapshot so the failed turn's user message isn't
4935
+ // stranded. `writeTurnCompleteChunk` already advanced the `.in`
4936
+ // cursor past it (via the session-in-event-id header), and the
4937
+ // success-path snapshot write is skipped on error — without this
4938
+ // the next boot would resume past a message that exists in
4939
+ // neither the snapshot nor the replayable `.in` tail.
4940
+ if (!hydrateMessages) {
4941
+ try {
4942
+ const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4943
+ await writeChatSnapshot(sessionIdForSnapshot, {
4944
+ version: 1,
4945
+ savedAt: Date.now(),
4946
+ messages: erroredUIMessages,
4947
+ lastOutEventId: errorTurnCompleteResult?.lastEventId,
4948
+ lastInEventId: errorSnapshotInCursor !== undefined
4949
+ ? String(errorSnapshotInCursor)
4950
+ : undefined,
4951
+ });
4952
+ }
4953
+ catch (error) {
4954
+ v3_1.logger.warn("chat.agent: error-path snapshot write failed", {
4955
+ error: error instanceof Error ? error.message : String(error),
4956
+ sessionId: sessionIdForSnapshot,
4957
+ });
4958
+ }
4959
+ }
4673
4960
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
4674
4961
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) ||
4675
4962
  locals_js_1.locals.get(chatEndRunRequestedKey)) {
@@ -5290,8 +5577,19 @@ async function pipeChatAndCapture(source, options) {
5290
5577
  const onFinishPromise = new Promise((r) => {
5291
5578
  resolveOnFinish = r;
5292
5579
  });
5580
+ const resolvedOptions = resolveUIMessageStreamOptions();
5293
5581
  const uiStream = source.toUIMessageStream({
5294
- ...resolveUIMessageStreamOptions(),
5582
+ ...resolvedOptions,
5583
+ // Thread the prior chain (incl. a spliced handover partial) so a resumed
5584
+ // tool round's tool-output chunks merge into the originating tool-call
5585
+ // instead of throwing "No tool invocation found".
5586
+ ...(options?.originalMessages ? { originalMessages: options.originalMessages } : {}),
5587
+ // Stamp a server-generated id on the start chunk, same as chat.agent's
5588
+ // pipe. Without it the AI SDK regenerates the assistant id when a
5589
+ // prepareStep injection (steering) starts a new step mid-stream, and
5590
+ // the frontend replaces the partial message — wiping the
5591
+ // pre-injection text from the UI and the captured response.
5592
+ generateMessageId: resolvedOptions.generateMessageId ?? ai_runtime_js_1.generateId,
5295
5593
  onFinish: ({ responseMessage }) => {
5296
5594
  captured = responseMessage;
5297
5595
  resolveOnFinish();
@@ -5361,10 +5659,65 @@ class ChatMessageAccumulator {
5361
5659
  this.uiMessages = [...uiMessages];
5362
5660
  this.modelMessages = await toModelMessages(uiMessages);
5363
5661
  }
5662
+ /**
5663
+ * Splice a `chat.headStart` handover partial into the accumulator (the warm
5664
+ * step-1 response). Dedups by `messageId` so a seeded/hydrated history that
5665
+ * already carries the partial isn't doubled. Seed any prior history first
5666
+ * (e.g. `setMessages(payload.headStartMessages)`). Low-level — see
5667
+ * `consumeHandover` for the wait+seed+apply convenience.
5668
+ */
5669
+ applyHandover(signal) {
5670
+ spliceHandoverPartial(this.modelMessages, this.uiMessages, signal);
5671
+ }
5672
+ /**
5673
+ * One-call `chat.headStart` handover for a custom-agent loop: waits for the
5674
+ * handover signal, seeds prior history from `payload.headStartMessages`,
5675
+ * applies the warm step-1 partial, and reports what to do next.
5676
+ *
5677
+ * Returns `{ isFinal, skipped }`:
5678
+ * - `skipped: true` — not a `handover-prepare` run, the wait idled out, or the
5679
+ * warm handler aborted. Exit the run without a turn.
5680
+ * - `isFinal: true` — step 1 IS the response (pure text). Write turn-complete
5681
+ * and continue; do not call `streamText`.
5682
+ * - `isFinal: false` — fall through to `streamText`, which runs the pending
5683
+ * tool round handed over from step 1.
5684
+ */
5685
+ async consumeHandover(options) {
5686
+ const signal = await waitForHandover({
5687
+ payload: options.payload,
5688
+ idleTimeoutInSeconds: options.idleTimeoutInSeconds,
5689
+ timeout: options.timeout,
5690
+ });
5691
+ if (!signal || signal.kind === "handover-skip") {
5692
+ return { isFinal: false, skipped: true };
5693
+ }
5694
+ if (options.payload.headStartMessages && options.payload.headStartMessages.length > 0) {
5695
+ await this.setMessages(options.payload.headStartMessages);
5696
+ }
5697
+ this.applyHandover(signal);
5698
+ return { isFinal: signal.isFinal, skipped: false };
5699
+ }
5364
5700
  async addResponse(response) {
5365
5701
  if (!response.id) {
5366
5702
  response = { ...response, id: (0, ai_runtime_js_1.generateId)() };
5367
5703
  }
5704
+ // Tool-approval and handover-resume continuations reuse the trailing
5705
+ // assistant's ID (via originalMessages on the pipe), so the captured
5706
+ // response can carry the same ID as a message already in the chain
5707
+ // (e.g. a spliced handover partial). Replace in place instead of pushing
5708
+ // a duplicate, mirroring the chat.agent accumulator.
5709
+ const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
5710
+ if (existingIdx !== -1) {
5711
+ this.uiMessages[existingIdx] = response;
5712
+ try {
5713
+ // Reconvert all model messages since we replaced rather than appended.
5714
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
5715
+ }
5716
+ catch {
5717
+ // Conversion failed — leave the existing model messages in place
5718
+ }
5719
+ return;
5720
+ }
5368
5721
  this.uiMessages.push(response);
5369
5722
  try {
5370
5723
  const msgs = await toModelMessages([stripProviderMetadata(response)]);
@@ -5497,14 +5850,18 @@ class ChatMessageAccumulator {
5497
5850
  * signaling, and idle/suspend between turns. You control: initialization,
5498
5851
  * model/tool selection, persistence, and any custom per-turn logic.
5499
5852
  *
5853
+ * Call from inside a `chat.customAgent()` run — the wrapper binds the
5854
+ * backing Session that the iterator's stop signal and message channels
5855
+ * resolve to. (A plain `task()` does not bind it, so `createSession`
5856
+ * would throw "session handle is not initialized".)
5857
+ *
5500
5858
  * @example
5501
5859
  * ```ts
5502
- * import { task } from "@trigger.dev/sdk";
5503
5860
  * import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
5504
5861
  * import { streamText } from "ai";
5505
5862
  * import { openai } from "@ai-sdk/openai";
5506
5863
  *
5507
- * export const myChat = task({
5864
+ * export const myChat = chat.customAgent({
5508
5865
  * id: "my-chat",
5509
5866
  * run: async (payload: ChatTaskWirePayload, { signal }) => {
5510
5867
  * const session = chat.createSession(payload, { signal });
@@ -5528,25 +5885,72 @@ function createChatSession(payload, options) {
5528
5885
  [Symbol.asyncIterator]() {
5529
5886
  let currentPayload = payload;
5530
5887
  let turn = -1;
5531
- const stop = createStopSignal();
5888
+ // Created on the first next() call, AFTER the resume-cursor seed —
5889
+ // createStopSignal attaches the `.in` SSE tail, and attaching
5890
+ // before the seed replays every record from seq 0 (the seed is a
5891
+ // no-op when the chatCustomAgent wrapper already ran it).
5892
+ let stop;
5893
+ let booted = false;
5532
5894
  const accumulator = new ChatMessageAccumulator();
5533
5895
  let previousTurnUsage;
5534
5896
  let cumulativeUsage = emptyUsage();
5535
5897
  return {
5536
5898
  async next() {
5899
+ if (!booted) {
5900
+ booted = true;
5901
+ await seedSessionInResumeCursorForCustomLoop(currentPayload);
5902
+ stop = createStopSignal();
5903
+ }
5537
5904
  turn++;
5538
- // First turn: handle preload wait for the first real message
5539
- if (turn === 0 && currentPayload.trigger === "preload") {
5905
+ // Head-start handover: the server triggered this run with
5906
+ // `trigger: "handover-prepare"` and signals the warm step-1 partial on
5907
+ // `session.in`. Wait for it BEFORE any `messagesInput.waitWithIdleTimeout`
5908
+ // (that facade consumes-and-discards non-message chunks and would swallow
5909
+ // the signal). Turn-0 only — continuation boots never carry this trigger.
5910
+ let handoverThisTurn = null;
5911
+ let pendingHandoverSignal = null;
5912
+ if (turn === 0 && currentPayload.trigger === "handover-prepare") {
5913
+ const signal = await waitForHandover({
5914
+ payload: currentPayload,
5915
+ idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? idleTimeoutInSeconds,
5916
+ timeout,
5917
+ });
5918
+ if (!signal || signal.kind === "handover-skip" || runSignal.aborted) {
5919
+ stop.cleanup();
5920
+ return { done: true, value: undefined };
5921
+ }
5922
+ pendingHandoverSignal = signal;
5923
+ handoverThisTurn = { isFinal: signal.isFinal };
5924
+ // Rewrite to a normal first-turn message turn so the rest of the loop
5925
+ // (steering setup, addIncoming, turnObj) runs unchanged.
5926
+ currentPayload = { ...currentPayload, trigger: "submit-message", message: undefined };
5927
+ }
5928
+ // First turn: wait when the boot payload carries no message.
5929
+ // Preload boots wait for the first real message; continuation
5930
+ // boots (fresh run via `ensureRunForSession` / end-and-continue)
5931
+ // arrive with the sticky boot-payload fields stripped, so running
5932
+ // a turn immediately would invoke the model with no user input.
5933
+ const isMessagelessContinuationBoot = currentPayload.continuation === true && !currentPayload.message;
5934
+ if (turn === 0 && (currentPayload.trigger === "preload" || isMessagelessContinuationBoot)) {
5540
5935
  const result = await messagesInput.waitWithIdleTimeout({
5541
5936
  idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
5542
5937
  timeout,
5543
- spanName: "waiting for first message",
5938
+ spanName: currentPayload.trigger === "preload"
5939
+ ? "waiting for first message"
5940
+ : "waiting for first message (continuation)",
5544
5941
  });
5545
5942
  if (!result.ok || runSignal.aborted) {
5546
5943
  stop.cleanup();
5547
5944
  return { done: true, value: undefined };
5548
5945
  }
5946
+ const continuationBoot = isMessagelessContinuationBoot;
5549
5947
  currentPayload = result.output;
5948
+ // Preserve the continuation flag — the wire payload of the next
5949
+ // message doesn't carry it, and `turn.continuation` is how the
5950
+ // user knows to seed history (e.g. `turn.setMessages(stored)`).
5951
+ if (continuationBoot && currentPayload.continuation === undefined) {
5952
+ currentPayload = { ...currentPayload, continuation: true };
5953
+ }
5550
5954
  }
5551
5955
  // Subsequent turns: wait for the next message
5552
5956
  if (turn > 0) {
@@ -5627,6 +6031,16 @@ function createChatSession(payload, options) {
5627
6031
  ? [currentPayload.message]
5628
6032
  : [];
5629
6033
  const messages = await accumulator.addIncoming(incomingForAccumulator, currentPayload.trigger, turn);
6034
+ // Apply the head-start handover AFTER addIncoming — turn-0 addIncoming
6035
+ // replaces accumulator state, which would wipe a pre-applied splice.
6036
+ // Seed prior history first, then splice the warm step-1 partial.
6037
+ if (pendingHandoverSignal) {
6038
+ const priorHistory = currentPayload.headStartMessages;
6039
+ if (priorHistory && priorHistory.length > 0) {
6040
+ await accumulator.setMessages(priorHistory);
6041
+ }
6042
+ accumulator.applyHandover(pendingHandoverSignal);
6043
+ }
5630
6044
  // chat.requestUpgrade() called before this turn — signal transport and exit
5631
6045
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
5632
6046
  await writeUpgradeRequiredChunk();
@@ -5653,13 +6067,38 @@ function createChatSession(payload, options) {
5653
6067
  continuation: currentPayload.continuation ?? false,
5654
6068
  previousTurnUsage,
5655
6069
  totalUsage: cumulativeUsage,
6070
+ handover: handoverThisTurn,
5656
6071
  async setMessages(uiMessages) {
5657
6072
  await accumulator.setMessages(uiMessages);
5658
6073
  },
5659
6074
  async complete(source) {
6075
+ // Head-start final turn: the warm step-1 partial is already spliced
6076
+ // into the accumulator and IS the response — nothing to pipe. Only
6077
+ // valid on a final handover; a missing source on any other turn is a
6078
+ // mistake (it would silently finalize without an assistant response).
6079
+ if (!source) {
6080
+ if (!handoverThisTurn?.isFinal) {
6081
+ throw new Error("turn.complete() requires a stream source unless turn.handover.isFinal is true");
6082
+ }
6083
+ const response = accumulator.uiMessages.at(-1);
6084
+ if (!response || response.role !== "assistant") {
6085
+ throw new Error("turn.complete() could not find the spliced handover response");
6086
+ }
6087
+ sessionMsgSub.off();
6088
+ await chatWriteTurnComplete();
6089
+ return response;
6090
+ }
5660
6091
  let response;
5661
6092
  try {
5662
- response = await pipeChatAndCapture(source, { signal: combinedSignal });
6093
+ response = await pipeChatAndCapture(source, {
6094
+ signal: combinedSignal,
6095
+ // On a non-final handover turn, thread the spliced partial so a
6096
+ // resumed tool round's tool-output chunks merge into the
6097
+ // handed-over tool-call. Gated on the handover turn only — a
6098
+ // normal turn must not pass originalMessages (it would merge the
6099
+ // fresh response into the prior assistant message).
6100
+ ...(handoverThisTurn ? { originalMessages: accumulator.uiMessages } : {}),
6101
+ });
5663
6102
  }
5664
6103
  catch (error) {
5665
6104
  if (error instanceof Error && error.name === "AbortError") {
@@ -5699,14 +6138,22 @@ function createChatSession(payload, options) {
5699
6138
  locals_js_1.locals.set(chatResponsePartsKey, []);
5700
6139
  }
5701
6140
  }
5702
- // Capture token usage from the streamText result
6141
+ // Capture token usage from the streamText result. Race with a 2s
6142
+ // timeout — on stop-abort the AI SDK's totalUsage promise can hang
6143
+ // indefinitely, which would wedge the turn loop (same guard as
6144
+ // chat.agent's turn loop).
5703
6145
  let turnUsage;
5704
6146
  if (typeof source.totalUsage?.then === "function") {
5705
6147
  try {
5706
- const usage = await source.totalUsage;
5707
- turnUsage = usage;
5708
- previousTurnUsage = usage;
5709
- cumulativeUsage = addUsage(cumulativeUsage, usage);
6148
+ const usage = (await Promise.race([
6149
+ source.totalUsage,
6150
+ new Promise((r) => setTimeout(() => r(undefined), 2_000)),
6151
+ ]));
6152
+ if (usage) {
6153
+ turnUsage = usage;
6154
+ previousTurnUsage = usage;
6155
+ cumulativeUsage = addUsage(cumulativeUsage, usage);
6156
+ }
5710
6157
  }
5711
6158
  catch {
5712
6159
  /* non-fatal */
@@ -5806,7 +6253,8 @@ function createChatSession(payload, options) {
5806
6253
  return { done: false, value: turnObj };
5807
6254
  },
5808
6255
  async return() {
5809
- stop.cleanup();
6256
+ // `stop` only exists once next() has booted the iterator.
6257
+ stop?.cleanup();
5810
6258
  return { done: true, value: undefined };
5811
6259
  },
5812
6260
  };
@@ -6049,6 +6497,7 @@ function createChatStartSessionAction(taskId, options) {
6049
6497
  // run-list filter by chat works without the customer having to wire it
6050
6498
  // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6051
6499
  const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6500
+ // SessionTriggerConfig.tags allows at most 5; the auto chat tag takes one slot.
6052
6501
  const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
6053
6502
  const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
6054
6503
  const triggerConfig = {
@@ -6073,6 +6522,20 @@ function createChatStartSessionAction(taskId, options) {
6073
6522
  maxAttempts: params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts,
6074
6523
  }
6075
6524
  : {}),
6525
+ ...(options?.triggerConfig?.maxDuration !== undefined ||
6526
+ params.triggerConfig?.maxDuration !== undefined
6527
+ ? {
6528
+ maxDuration: params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration,
6529
+ }
6530
+ : {}),
6531
+ ...(options?.triggerConfig?.region || params.triggerConfig?.region
6532
+ ? { region: params.triggerConfig?.region ?? options?.triggerConfig?.region }
6533
+ : {}),
6534
+ ...(options?.triggerConfig?.lockToVersion || params.triggerConfig?.lockToVersion
6535
+ ? {
6536
+ lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
6537
+ }
6538
+ : {}),
6076
6539
  ...(options?.triggerConfig?.idleTimeoutInSeconds !== undefined ||
6077
6540
  params.triggerConfig?.idleTimeoutInSeconds !== undefined
6078
6541
  ? {
@@ -6251,10 +6714,20 @@ exports.chat = {
6251
6714
  MessageAccumulator: ChatMessageAccumulator,
6252
6715
  /** Create a chat session (async iterator). See {@link createChatSession}. */
6253
6716
  createSession: createChatSession,
6717
+ /**
6718
+ * Wait for a `chat.headStart` handover signal inside a `chat.customAgent`
6719
+ * loop (turn 0). See {@link waitForHandover}. For most loops prefer the
6720
+ * `chat.MessageAccumulator.consumeHandover()` convenience, which also seeds
6721
+ * `payload.headStartMessages` and applies the partial.
6722
+ */
6723
+ waitForHandover,
6254
6724
  /**
6255
6725
  * Store and retrieve a resolved prompt for the current run.
6256
6726
  *
6257
6727
  * - `chat.prompt.set(resolved)` — store a `ResolvedPrompt` or plain string
6728
+ * - `chat.prompt.set(resolved, { providerOptions })` — also attach provider
6729
+ * options to the system block so a provider can cache it (e.g. Anthropic
6730
+ * prompt caching). See the prompt-caching guide.
6258
6731
  * - `chat.prompt()` — read the stored prompt (throws if not set)
6259
6732
  */
6260
6733
  prompt: Object.assign(getChatPrompt, { set: setChatPrompt }),
@@ -6347,8 +6820,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
6347
6820
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
6348
6821
  // first-turn-ever (or first turn post-OOM without a snapshot seed) is
6349
6822
  // fine — the chain catches up next turn.
6350
- const slot = locals_js_1.locals.get(lastTurnCompleteSeqNumKey);
6351
- const prev = slot?.value;
6823
+ //
6824
+ // Lazily create the slot if a caller reached here without one (a plain
6825
+ // `task()` driving `chat.createSession` / `chat.writeTurnComplete`, vs.
6826
+ // chatAgent/chatCustomAgent which seed it at boot). The first call then
6827
+ // does no trim (nothing before it) and records its seq; later calls trim
6828
+ // — so `.out` is bounded for every writeTurnComplete caller, not just the
6829
+ // built-in agents.
6830
+ let slot = locals_js_1.locals.get(lastTurnCompleteSeqNumKey);
6831
+ if (!slot) {
6832
+ slot = { value: undefined };
6833
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, slot);
6834
+ }
6835
+ const prev = slot.value;
6352
6836
  if (slot && prev !== undefined) {
6353
6837
  try {
6354
6838
  await session.out.trimTo(prev);