@trigger.dev/sdk 4.5.16 → 4.6.0

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 (156) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +2 -0
  3. package/dist/commonjs/v3/ai-shared.d.ts +15 -0
  4. package/dist/commonjs/v3/ai-shared.js +35 -1
  5. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  6. package/dist/commonjs/v3/ai.d.ts +311 -25
  7. package/dist/commonjs/v3/ai.js +1633 -380
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/auth.d.ts +10 -4
  10. package/dist/commonjs/v3/auth.js.map +1 -1
  11. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  12. package/dist/commonjs/v3/chat-client.js +227 -111
  13. package/dist/commonjs/v3/chat-client.js.map +1 -1
  14. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  15. package/dist/commonjs/v3/chat-react.js +130 -1
  16. package/dist/commonjs/v3/chat-react.js.map +1 -1
  17. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  18. package/dist/commonjs/v3/chat-server.js +52 -7
  19. package/dist/commonjs/v3/chat-server.js.map +1 -1
  20. package/dist/commonjs/v3/chat.d.ts +92 -10
  21. package/dist/commonjs/v3/chat.js +328 -73
  22. package/dist/commonjs/v3/chat.js.map +1 -1
  23. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  24. package/dist/commonjs/v3/chatSnapshotIo.js +173 -0
  25. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  26. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  27. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  28. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  29. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  30. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  31. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  32. package/dist/commonjs/v3/retry.d.ts +4 -0
  33. package/dist/commonjs/v3/retry.js +22 -13
  34. package/dist/commonjs/v3/retry.js.map +1 -1
  35. package/dist/commonjs/v3/sessions.d.ts +15 -2
  36. package/dist/commonjs/v3/sessions.js +12 -1
  37. package/dist/commonjs/v3/sessions.js.map +1 -1
  38. package/dist/commonjs/v3/shared.js +30 -36
  39. package/dist/commonjs/v3/shared.js.map +1 -1
  40. package/dist/commonjs/v3/test/index.d.ts +1 -0
  41. package/dist/commonjs/v3/test/index.js +3 -1
  42. package/dist/commonjs/v3/test/index.js.map +1 -1
  43. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  44. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  45. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  46. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  47. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  48. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  49. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  50. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  51. package/dist/commonjs/v3/transcriptStorage.d.ts +261 -0
  52. package/dist/commonjs/v3/transcriptStorage.js +374 -0
  53. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  54. package/dist/commonjs/version.js +1 -1
  55. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  56. package/dist/esm/imports/ai-runtime.js +2 -2
  57. package/dist/esm/imports/ai-runtime.js.map +1 -1
  58. package/dist/esm/v3/ai-shared.d.ts +15 -0
  59. package/dist/esm/v3/ai-shared.js +33 -0
  60. package/dist/esm/v3/ai-shared.js.map +1 -1
  61. package/dist/esm/v3/ai.d.ts +311 -25
  62. package/dist/esm/v3/ai.js +1625 -377
  63. package/dist/esm/v3/ai.js.map +1 -1
  64. package/dist/esm/v3/auth.d.ts +10 -4
  65. package/dist/esm/v3/auth.js.map +1 -1
  66. package/dist/esm/v3/chat-client.d.ts +11 -2
  67. package/dist/esm/v3/chat-client.js +228 -112
  68. package/dist/esm/v3/chat-client.js.map +1 -1
  69. package/dist/esm/v3/chat-react.d.ts +88 -0
  70. package/dist/esm/v3/chat-react.js +127 -1
  71. package/dist/esm/v3/chat-react.js.map +1 -1
  72. package/dist/esm/v3/chat-server.d.ts +42 -6
  73. package/dist/esm/v3/chat-server.js +53 -8
  74. package/dist/esm/v3/chat-server.js.map +1 -1
  75. package/dist/esm/v3/chat.d.ts +92 -10
  76. package/dist/esm/v3/chat.js +329 -74
  77. package/dist/esm/v3/chat.js.map +1 -1
  78. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  79. package/dist/esm/v3/chatSnapshotIo.js +165 -0
  80. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  81. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  82. package/dist/esm/v3/chatVersionSkew.js +27 -0
  83. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  84. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  85. package/dist/esm/v3/externalDeploymentId.js +38 -0
  86. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  87. package/dist/esm/v3/retry.d.ts +4 -0
  88. package/dist/esm/v3/retry.js +14 -8
  89. package/dist/esm/v3/retry.js.map +1 -1
  90. package/dist/esm/v3/sessions.d.ts +15 -2
  91. package/dist/esm/v3/sessions.js +11 -1
  92. package/dist/esm/v3/sessions.js.map +1 -1
  93. package/dist/esm/v3/shared.js +17 -23
  94. package/dist/esm/v3/shared.js.map +1 -1
  95. package/dist/esm/v3/test/index.d.ts +1 -0
  96. package/dist/esm/v3/test/index.js +1 -0
  97. package/dist/esm/v3/test/index.js.map +1 -1
  98. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  99. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  100. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  101. package/dist/esm/v3/test/test-session-handle.js +6 -0
  102. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  103. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  104. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  105. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  106. package/dist/esm/v3/transcriptStorage.d.ts +261 -0
  107. package/dist/esm/v3/transcriptStorage.js +360 -0
  108. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  109. package/dist/esm/version.js +1 -1
  110. package/docs/ai-chat/actions.mdx +55 -23
  111. package/docs/ai-chat/anatomy.mdx +47 -27
  112. package/docs/ai-chat/backend.mdx +125 -48
  113. package/docs/ai-chat/background-injection.mdx +122 -19
  114. package/docs/ai-chat/client-protocol.mdx +5 -4
  115. package/docs/ai-chat/compaction.mdx +13 -7
  116. package/docs/ai-chat/custom-agents.mdx +23 -0
  117. package/docs/ai-chat/fast-starts.mdx +27 -20
  118. package/docs/ai-chat/frontend.mdx +19 -14
  119. package/docs/ai-chat/how-it-works.mdx +1 -1
  120. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  121. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  122. package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
  123. package/docs/ai-chat/patterns/database-persistence.mdx +24 -9
  124. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  125. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  126. package/docs/ai-chat/patterns/skills.mdx +7 -10
  127. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  128. package/docs/ai-chat/pending-messages.mdx +3 -3
  129. package/docs/ai-chat/prompt-caching.mdx +23 -25
  130. package/docs/ai-chat/quick-start.mdx +11 -11
  131. package/docs/ai-chat/reference.mdx +69 -10
  132. package/docs/ai-chat/sessions.mdx +6 -1
  133. package/docs/ai-chat/side-channels.mdx +1 -1
  134. package/docs/ai-chat/testing.mdx +2 -1
  135. package/docs/ai-chat/tools.mdx +18 -13
  136. package/docs/ai-chat/transcript-storage.mdx +247 -0
  137. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  138. package/docs/apikeys.mdx +27 -45
  139. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  140. package/docs/deploy-environment-variables.mdx +23 -2
  141. package/docs/deployment/overview.mdx +4 -8
  142. package/docs/deployment/preview-branches.mdx +4 -4
  143. package/docs/deployment/version-skew-protection.mdx +62 -0
  144. package/docs/introduction.mdx +58 -152
  145. package/docs/manual-setup.mdx +7 -7
  146. package/docs/mcp-tools.mdx +9 -0
  147. package/docs/quick-start.mdx +3 -3
  148. package/docs/realtime/auth.mdx +1 -1
  149. package/docs/reports.mdx +157 -0
  150. package/docs/self-hosting/security.mdx +17 -0
  151. package/docs/tasks/scheduled.mdx +24 -0
  152. package/docs/triggering.mdx +1 -1
  153. package/docs/troubleshooting-zod.mdx +158 -0
  154. package/package.json +4 -4
  155. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  156. package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
@@ -1,10 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.chat = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = void 0;
4
- exports.__setReadChatSnapshotImplForTests = __setReadChatSnapshotImplForTests;
5
- exports.__setWriteChatSnapshotImplForTests = __setWriteChatSnapshotImplForTests;
6
- exports.__readChatSnapshotProductionPathForTests = __readChatSnapshotProductionPathForTests;
7
- exports.__writeChatSnapshotProductionPathForTests = __writeChatSnapshotProductionPathForTests;
3
+ exports.chat = exports.__buildManagedStreamTextOptionsForTests = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = exports.snapshotTranscriptStorage = exports.reduceTranscriptChanges = exports.memoryTranscriptStorage = exports.defaultStorage = exports.__writeChatSnapshotProductionPathForTests = exports.__setWriteChatSnapshotImplForTests = exports.__setReadChatSnapshotImplForTests = exports.__readChatSnapshotProductionPathForTests = void 0;
4
+ exports.__setTranscriptStorageForTests = __setTranscriptStorageForTests;
8
5
  exports.__mergeByIdReplaceWinsForTests = __mergeByIdReplaceWinsForTests;
9
6
  exports.__setReplaySessionOutTailImplForTests = __setReplaySessionOutTailImplForTests;
10
7
  exports.__replaySessionOutTailProductionPathForTests = __replaySessionOutTailProductionPathForTests;
@@ -19,6 +16,16 @@ const v3_1 = require("@trigger.dev/core/v3");
19
16
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
20
17
  const api_1 = require("@opentelemetry/api");
21
18
  const ai_runtime_js_1 = require("../imports/ai-runtime.js");
19
+ const transcriptStorage_js_1 = require("./transcriptStorage.js");
20
+ let transcriptStorageOverride;
21
+ /**
22
+ * Test-only override for the storage `chat.agent` persists through, so a
23
+ * test can capture the exact changesets the runtime produces.
24
+ * @internal
25
+ */
26
+ function __setTranscriptStorageForTests(storage) {
27
+ transcriptStorageOverride = storage;
28
+ }
22
29
  const ai_shared_js_1 = require("./ai-shared.js");
23
30
  Object.defineProperty(exports, "PENDING_MESSAGE_INJECTED_TYPE", { enumerable: true, get: function () { return ai_shared_js_1.PENDING_MESSAGE_INJECTED_TYPE; } });
24
31
  Object.defineProperty(exports, "upsertIncomingMessage", { enumerable: true, get: function () { return ai_shared_js_1.upsertIncomingMessage; } });
@@ -35,6 +42,8 @@ const metadata_js_1 = require("./metadata.js");
35
42
  // pulled in transitively here never reach a client chunk.
36
43
  const agentSkillsRuntime_js_1 = require("./agentSkillsRuntime.js");
37
44
  const aiAutoTelemetry_js_1 = require("./aiAutoTelemetry.js");
45
+ const externalDeploymentId_js_1 = require("./externalDeploymentId.js");
46
+ const chatVersionSkew_js_1 = require("./chatVersionSkew.js");
38
47
  const sessions_js_1 = require("./sessions.js");
39
48
  const shared_js_1 = require("./shared.js");
40
49
  const streams_js_1 = require("./streams.js");
@@ -121,182 +130,16 @@ async function findLatestSessionInCursor(chatId) {
121
130
  }
122
131
  return latestCursor;
123
132
  }
124
- let readChatSnapshotImpl;
125
- function __setReadChatSnapshotImplForTests(impl) {
126
- readChatSnapshotImpl = impl;
127
- }
128
- let writeChatSnapshotImpl;
129
- function __setWriteChatSnapshotImplForTests(impl) {
130
- writeChatSnapshotImpl = impl;
131
- }
132
- /**
133
- * Read the persisted snapshot for a session. Returns `undefined` on:
134
- * - missing object (404 from the presigned GET — fresh session, never
135
- * persisted)
136
- * - presign failure (network/auth issue)
137
- * - malformed JSON
138
- * - version mismatch (forward-compat — older runtimes ignore newer blobs)
139
- *
140
- * Always swallows errors via `logger.warn`. The agent boot loop must stay
141
- * available even if S3 hiccups; the worst case is replaying more of
142
- * `session.out` than strictly necessary.
143
- * @internal
144
- */
145
- async function readChatSnapshot(sessionId) {
146
- if (readChatSnapshotImpl) {
147
- return (await readChatSnapshotImpl(sessionId)) ?? undefined;
148
- }
149
- const apiClient = v3_1.apiClientManager.clientOrThrow();
150
- let presignedUrl;
151
- try {
152
- const resp = await apiClient.getChatSnapshotUrl(sessionId);
153
- presignedUrl = resp.presignedUrl;
154
- }
155
- catch (error) {
156
- v3_1.logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", {
157
- error: error instanceof Error ? error.message : String(error),
158
- sessionId,
159
- });
160
- return undefined;
161
- }
162
- let response;
163
- try {
164
- response = await fetch(presignedUrl, { method: "GET" });
165
- }
166
- catch (error) {
167
- v3_1.logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", {
168
- error: error instanceof Error ? error.message : String(error),
169
- sessionId,
170
- });
171
- return undefined;
172
- }
173
- if (response.status === 404) {
174
- // First-ever boot for this session — no snapshot yet. Caller falls
175
- // through to replay-only.
176
- return undefined;
177
- }
178
- if (!response.ok) {
179
- v3_1.logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", {
180
- status: response.status,
181
- sessionId,
182
- });
183
- return undefined;
184
- }
185
- let parsed;
186
- try {
187
- parsed = await response.json();
188
- }
189
- catch (error) {
190
- v3_1.logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", {
191
- error: error instanceof Error ? error.message : String(error),
192
- sessionId,
193
- });
194
- return undefined;
195
- }
196
- if (!parsed || typeof parsed !== "object")
197
- return undefined;
198
- const candidate = parsed;
199
- if (candidate.version !== 1 || !Array.isArray(candidate.messages)) {
200
- v3_1.logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", {
201
- version: candidate.version,
202
- sessionId,
203
- });
204
- return undefined;
205
- }
206
- return candidate;
207
- }
208
- /**
209
- * Persist the snapshot for a session. Awaited by callers immediately after
210
- * `onTurnComplete` — the agent may suspend right after this point, and
211
- * fire-and-forget promises don't reliably complete on suspend.
212
- *
213
- * Errors are swallowed via `logger.warn`. A failed write means the next
214
- * boot replays slightly more of `session.out` (back to the previous
215
- * snapshot's cursor) instead of failing — the conversation stays
216
- * coherent, only the boot path does marginally more work.
217
- * @internal
218
- */
219
- async function writeChatSnapshot(sessionId, snapshot) {
220
- if (writeChatSnapshotImpl) {
221
- await writeChatSnapshotImpl(sessionId, snapshot);
222
- return;
223
- }
224
- const apiClient = v3_1.apiClientManager.clientOrThrow();
225
- let presignedUrl;
226
- try {
227
- const resp = await apiClient.createChatSnapshotUploadUrl(sessionId);
228
- presignedUrl = resp.presignedUrl;
229
- }
230
- catch (error) {
231
- v3_1.logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", {
232
- error: error instanceof Error ? error.message : String(error),
233
- sessionId,
234
- });
235
- return;
236
- }
237
- let response;
238
- try {
239
- response = await fetch(presignedUrl, {
240
- method: "PUT",
241
- headers: { "content-type": "application/json" },
242
- body: JSON.stringify(snapshot),
243
- });
244
- }
245
- catch (error) {
246
- v3_1.logger.warn("chat.agent: snapshot upload failed; next run will replay further", {
247
- error: error instanceof Error ? error.message : String(error),
248
- sessionId,
249
- });
250
- return;
251
- }
252
- if (!response.ok) {
253
- v3_1.logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", {
254
- status: response.status,
255
- sessionId,
256
- });
257
- }
258
- }
259
- /**
260
- * Test-only entry point that bypasses `__setReadChatSnapshotImplForTests`
261
- * and reaches the real `apiClient.getPayloadUrl` + `fetch` + JSON-parse path.
262
- * Used by `chat-snapshot.test.ts` to verify 404 / 500 / malformed JSON /
263
- * version-mismatch / network-error behavior end-to-end. Tests mock global
264
- * `fetch` and the api-client config; this wrapper lets them drive the
265
- * production code without the override hook short-circuiting.
266
- *
267
- * Not part of the public API. The `__` prefix and `ForTests` suffix mirror
268
- * the override-hook setters above.
269
- * @internal
270
- */
271
- async function __readChatSnapshotProductionPathForTests(sessionId) {
272
- const saved = readChatSnapshotImpl;
273
- readChatSnapshotImpl = undefined;
274
- try {
275
- return await readChatSnapshot(sessionId);
276
- }
277
- finally {
278
- readChatSnapshotImpl = saved;
279
- }
280
- }
281
- /**
282
- * Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests`
283
- * and reaches the real `apiClient.createUploadPayloadUrl` + `fetch` PUT
284
- * path. Pairs with `__readChatSnapshotProductionPathForTests` — see that
285
- * function's note for the rationale.
286
- *
287
- * Not part of the public API.
288
- * @internal
289
- */
290
- async function __writeChatSnapshotProductionPathForTests(sessionId, snapshot) {
291
- const saved = writeChatSnapshotImpl;
292
- writeChatSnapshotImpl = undefined;
293
- try {
294
- await writeChatSnapshot(sessionId, snapshot);
295
- }
296
- finally {
297
- writeChatSnapshotImpl = saved;
298
- }
299
- }
133
+ var chatSnapshotIo_js_1 = require("./chatSnapshotIo.js");
134
+ Object.defineProperty(exports, "__readChatSnapshotProductionPathForTests", { enumerable: true, get: function () { return chatSnapshotIo_js_1.__readChatSnapshotProductionPathForTests; } });
135
+ Object.defineProperty(exports, "__setReadChatSnapshotImplForTests", { enumerable: true, get: function () { return chatSnapshotIo_js_1.__setReadChatSnapshotImplForTests; } });
136
+ Object.defineProperty(exports, "__setWriteChatSnapshotImplForTests", { enumerable: true, get: function () { return chatSnapshotIo_js_1.__setWriteChatSnapshotImplForTests; } });
137
+ Object.defineProperty(exports, "__writeChatSnapshotProductionPathForTests", { enumerable: true, get: function () { return chatSnapshotIo_js_1.__writeChatSnapshotProductionPathForTests; } });
138
+ var transcriptStorage_js_2 = require("./transcriptStorage.js");
139
+ Object.defineProperty(exports, "defaultStorage", { enumerable: true, get: function () { return transcriptStorage_js_2.defaultStorage; } });
140
+ Object.defineProperty(exports, "memoryTranscriptStorage", { enumerable: true, get: function () { return transcriptStorage_js_2.memoryTranscriptStorage; } });
141
+ Object.defineProperty(exports, "reduceTranscriptChanges", { enumerable: true, get: function () { return transcriptStorage_js_2.reduceTranscriptChanges; } });
142
+ Object.defineProperty(exports, "snapshotTranscriptStorage", { enumerable: true, get: function () { return transcriptStorage_js_2.snapshotTranscriptStorage; } });
300
143
  /**
301
144
  * Merge two `UIMessage[]` lists by `id`, with the second list winning on
302
145
  * collision. Used at run boot to combine the snapshot's persisted history
@@ -840,31 +683,158 @@ exports.ai = {
840
683
  function createChatAccessToken(taskId) {
841
684
  return auth_js_1.auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
842
685
  }
843
- // ---------------------------------------------------------------------------
844
- // Chat transport helpers — backend side
845
- // ---------------------------------------------------------------------------
686
+ function createChatOutGate() {
687
+ let resolveOpened;
688
+ const opened = new Promise((resolve) => {
689
+ resolveOpened = resolve;
690
+ });
691
+ const gate = {
692
+ pending: new Set(),
693
+ open: false,
694
+ opened,
695
+ failOpen() {
696
+ if (gate.open)
697
+ return;
698
+ gate.open = true;
699
+ resolveOpened();
700
+ },
701
+ };
702
+ return gate;
703
+ }
704
+ const chatOutGateKey = locals_js_1.locals.create("chat.outGate");
705
+ /**
706
+ * How long a write waits on the gate before giving up. A storage that hangs
707
+ * degrades to an ungated write rather than stalling the conversation.
708
+ * @internal
709
+ */
710
+ const CHAT_OUT_GATE_TIMEOUT_MS = 10_000;
846
711
  /**
847
- * Typed chat output stream `.writer()`, `.pipe()`, `.append()`, and
848
- * `.read()` methods pre-bound to this run's Session `.out` channel and
849
- * typed to `UIMessageChunk`.
712
+ * The ids to save non-final after a failed turn: the stream's partial answer,
713
+ * but only while the message under its id is still that partial by content.
714
+ * `onTurnComplete` may hand back a cloned history (same content, new objects),
715
+ * which keeps it partial, or replace it in place, which finishes it.
716
+ * @internal
717
+ */
718
+ function partialStillUnfinished(partial, fingerprint, messages) {
719
+ if (!partial || fingerprint === undefined)
720
+ return undefined;
721
+ const current = messages.find((message) => message.id === partial.id);
722
+ if (!current || (0, transcriptStorage_js_1.fingerprintMessage)(current) !== fingerprint)
723
+ return undefined;
724
+ return new Set([partial.id]);
725
+ }
726
+ async function awaitChatOutGate() {
727
+ const gate = locals_js_1.locals.get(chatOutGateKey);
728
+ if (!gate || gate.open)
729
+ return;
730
+ const deadline = Date.now() + CHAT_OUT_GATE_TIMEOUT_MS;
731
+ while (gate.pending.size > 0) {
732
+ const remaining = deadline - Date.now();
733
+ if (remaining <= 0) {
734
+ gate.failOpen();
735
+ return;
736
+ }
737
+ const waitingOn = [...gate.pending];
738
+ let timedOut = false;
739
+ let timer;
740
+ try {
741
+ await Promise.race([
742
+ Promise.allSettled(waitingOn),
743
+ gate.opened,
744
+ new Promise((resolve) => {
745
+ timer = setTimeout(() => {
746
+ timedOut = true;
747
+ resolve();
748
+ }, remaining);
749
+ }),
750
+ ]);
751
+ }
752
+ finally {
753
+ if (timer)
754
+ clearTimeout(timer);
755
+ }
756
+ if (gate.open)
757
+ return;
758
+ if (timedOut) {
759
+ gate.failOpen();
760
+ return;
761
+ }
762
+ for (const settled of waitingOn)
763
+ gate.pending.delete(settled);
764
+ }
765
+ }
766
+ /**
767
+ * Register work that must land before anything from this turn reaches the
768
+ * frontend.
769
+ *
770
+ * Like {@link chatDefer} the work starts immediately and is never awaited by
771
+ * the hook that registered it, so it runs alongside the model and costs no
772
+ * time to first token. Unlike `chat.defer`, the output stream waits for it:
773
+ * no chunk of the answer is written to the session until it settles. That
774
+ * makes it the right home for a write the next page load has to see (a
775
+ * conversation row, a message insert), because a reader that can see the
776
+ * answer can also see what the write persisted.
777
+ *
778
+ * Reach for `chat.defer` instead when the timing does not matter for a
779
+ * reload: analytics, audit logs, search-index updates.
780
+ *
781
+ * This is not a consistency barrier for the turn. The work is still in flight
782
+ * while the model runs, so a tool, a `prepareStep`, or anything else executing
783
+ * during the turn can still read the state as it was before the write. It
784
+ * orders the write against what the frontend can see, nothing more. When the
785
+ * turn's own code has to read the write back, `await` it instead and accept
786
+ * the cost.
787
+ *
788
+ * A write registered here that fails, or outlasts the internal timeout, lets
789
+ * the stream through rather than stalling the conversation.
850
790
  *
851
- * Use from within a `chat.agent` run to write custom chunks:
791
+ * @example
852
792
  * ```ts
853
- * const { waitUntilComplete } = chat.stream.writer({
854
- * execute: ({ write }) => {
855
- * write({ type: "text-start", id: "status-1" });
856
- * write({ type: "text-delta", id: "status-1", delta: "Processing..." });
857
- * write({ type: "text-end", id: "status-1" });
858
- * },
859
- * });
860
- * await waitUntilComplete();
793
+ * onTurnStart: async ({ chatId, uiMessages }) => {
794
+ * chat.deferBeforeOutput(
795
+ * db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
796
+ * );
797
+ * },
861
798
  * ```
862
- *
863
- * Backed by the Session primitive so a chat's output outlives any single
864
- * run — subscribers (browser transport, server-side `ChatStream`) read
865
- * the session's `.out`, not a per-run stream. Run-scoped `target`
866
- * options on `.pipe()` are honoured as no-ops; the session is the target.
867
799
  */
800
+ function chatDeferBeforeOutput(promiseOrFn) {
801
+ const gate = locals_js_1.locals.get(chatOutGateKey);
802
+ const work = typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn;
803
+ if (!gate || gate.open)
804
+ return;
805
+ gate.pending.add(work);
806
+ }
807
+ function gateWriterOptions(options) {
808
+ return {
809
+ ...options,
810
+ execute: async (api) => {
811
+ await awaitChatOutGate();
812
+ return await options.execute(api);
813
+ },
814
+ };
815
+ }
816
+ function gateOutStream(value) {
817
+ return (async function* () {
818
+ await awaitChatOutGate();
819
+ if (isReadableStream(value)) {
820
+ const reader = value.getReader();
821
+ try {
822
+ while (true) {
823
+ const { done, value: chunk } = await reader.read();
824
+ if (done)
825
+ break;
826
+ yield chunk;
827
+ }
828
+ }
829
+ finally {
830
+ reader.releaseLock();
831
+ }
832
+ }
833
+ else {
834
+ yield* value;
835
+ }
836
+ })();
837
+ }
868
838
  const chatStream = {
869
839
  // Stable opaque label for the run-scoped `RealtimeDefinedStream` shape.
870
840
  // `chatStream` is backed by the Session's `.out` channel — this id is
@@ -874,7 +844,7 @@ const chatStream = {
874
844
  id: "chat",
875
845
  pipe(value, options) {
876
846
  const { target: _target, ...sessionOptions } = (options ?? {});
877
- return getChatSession().out.pipe(value, sessionOptions);
847
+ return getChatSession().out.pipe(gateOutStream(value), sessionOptions);
878
848
  },
879
849
  async read(_runId, options) {
880
850
  // Session channels don't need a runId — the session is the address.
@@ -884,10 +854,11 @@ const chatStream = {
884
854
  },
885
855
  async append(value, options) {
886
856
  const { target: _target, ...sessionOptions } = (options ?? {});
857
+ await awaitChatOutGate();
887
858
  return getChatSession().out.append(value, sessionOptions);
888
859
  },
889
860
  writer(options) {
890
- return getChatSession().out.writer(options);
861
+ return getChatSession().out.writer(gateWriterOptions(options));
891
862
  },
892
863
  };
893
864
  // ---------------------------------------------------------------------------
@@ -937,9 +908,13 @@ function createLazyChatWriter() {
937
908
  let mergeImpl = null;
938
909
  let waitPromise = null;
939
910
  let resolveExecute = null;
911
+ let started = false;
912
+ const bufferedParts = [];
913
+ const bufferedStreams = [];
940
914
  function ensureInitialized() {
941
- if (writeImpl)
915
+ if (started)
942
916
  return;
917
+ started = true;
943
918
  const executePromise = new Promise((resolve) => {
944
919
  resolveExecute = resolve;
945
920
  });
@@ -949,7 +924,11 @@ function createLazyChatWriter() {
949
924
  execute: ({ write, merge }) => {
950
925
  writeImpl = write;
951
926
  mergeImpl = merge;
952
- return executePromise; // Keep execute alive until flush()
927
+ for (const part of bufferedParts.splice(0))
928
+ write(part);
929
+ for (const stream of bufferedStreams.splice(0))
930
+ merge(stream);
931
+ return executePromise;
953
932
  },
954
933
  });
955
934
  waitPromise = waitUntilComplete;
@@ -959,11 +938,17 @@ function createLazyChatWriter() {
959
938
  write(part) {
960
939
  ensureInitialized();
961
940
  queueResponsePart(part);
962
- writeImpl(part);
941
+ if (writeImpl)
942
+ writeImpl(part);
943
+ else
944
+ bufferedParts.push(part);
963
945
  },
964
946
  merge(stream) {
965
947
  ensureInitialized();
966
- mergeImpl(stream);
948
+ if (mergeImpl)
949
+ mergeImpl(stream);
950
+ else
951
+ bufferedStreams.push(stream);
967
952
  },
968
953
  },
969
954
  async flush() {
@@ -1045,7 +1030,7 @@ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1045
1030
  }
1046
1031
  }
1047
1032
  async function reportChatCustomAgentClientDataError(payload, error, options) {
1048
- const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
1033
+ const errorText = error instanceof Error && error.message ? error.message : "An unexpected error occurred";
1049
1034
  v3_1.logger.warn("chat.customAgent: clientData validation failed", {
1050
1035
  chatId: payload.chatId,
1051
1036
  trigger: payload.trigger,
@@ -1774,12 +1759,19 @@ async function installChatInputRouter(chatId, options) {
1774
1759
  }
1775
1760
  const router = entry.router;
1776
1761
  router.restore(checkpoint);
1762
+ if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) {
1763
+ router.markRecovered(options.recoveredSeqNums);
1764
+ }
1777
1765
  const floor = router.resumeFrom();
1778
1766
  if (floor !== undefined) {
1779
1767
  v3_1.sessionStreams.setLastSeqNum(chatId, "in", floor);
1780
1768
  v3_1.sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1781
1769
  }
1782
1770
  v3_1.sessionStreams.onRecord(chatId, "in", (record) => {
1771
+ // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1772
+ // re-deliver below it and a replayable route would re-queue it.
1773
+ if (floor !== undefined && record.seqNum <= floor)
1774
+ return true;
1783
1775
  router.ingest(record);
1784
1776
  return true;
1785
1777
  });
@@ -1951,6 +1943,35 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1951
1943
  * @internal
1952
1944
  */
1953
1945
  const chatBackgroundQueueKey = locals_js_1.locals.create("chat.backgroundQueue");
1946
+ /**
1947
+ * Background injections a step-boundary drain handed to the model this turn,
1948
+ * with the transcript message they followed. Reconciled into the model lane
1949
+ * and the persisted injections once the turn's response is in.
1950
+ */
1951
+ const chatPendingBackgroundKey = locals_js_1.locals.create("chat.pendingBackground");
1952
+ /**
1953
+ * System-role context injected mid-conversation, held for the instructions lane.
1954
+ *
1955
+ * Kept apart from the message queue because ai@7 rejects a system message inside
1956
+ * `messages` for every provider — `standardizePrompt` throws upstream of any
1957
+ * provider call, and its own advice is to use the instructions option. Instructions
1958
+ * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1959
+ * home: appended as another system block rather than smuggled into the transcript.
1960
+ *
1961
+ * This is also the only way to inject *trusted* context. A message injected as
1962
+ * `user` is untrusted by construction, and a well-aligned model treats it that
1963
+ * way — it will say so, and re-derive the answer from tools instead.
1964
+ */
1965
+ const chatInjectedInstructionsKey = locals_js_1.locals.create("chat.injectedInstructions");
1966
+ /**
1967
+ * What a turn already consumed from the instructions lane, so a second
1968
+ * `toStreamTextOptions()` call in the same turn sees the same blocks.
1969
+ *
1970
+ * Consumed blocks are moved here rather than left in the pending lane: leaving
1971
+ * them there means an injection made during the consumed turn sits behind them,
1972
+ * and clearing the lane on the next turn destroys both.
1973
+ */
1974
+ const chatInstructionsConsumedKey = locals_js_1.locals.create("chat.injectedInstructionsConsumed");
1954
1975
  /**
1955
1976
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1956
1977
  * same worker don't share state.
@@ -2461,18 +2482,63 @@ const chatToolsOptionKey = locals_js_1.locals.create("chat.toolsOption");
2461
2482
  const chatResolvedToolsKey = locals_js_1.locals.create("chat.resolvedTools");
2462
2483
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2463
2484
  const chatUpgradeRequestedKey = locals_js_1.locals.create("chat.upgradeRequested");
2485
+ /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2486
+ const chatUpgradeExternalDeploymentIdKey = locals_js_1.locals.create("chat.upgradeExternalDeploymentId");
2464
2487
  /**
2465
2488
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2466
2489
  * turn completes, without any upgrade semantics. Checked at the same
2467
2490
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2468
2491
  */
2469
2492
  const chatEndRunRequestedKey = locals_js_1.locals.create("chat.endRunRequested");
2493
+ /**
2494
+ * @internal Set by `chat.close()`. Holds the close request (and its reason)
2495
+ * for the rest of the run: the loop writes the terminal `session-closed`
2496
+ * record, closes the session row, and exits at the same post-turn /
2497
+ * pre-wait sites as `chatEndRunRequestedKey`.
2498
+ */
2499
+ const chatCloseRequestedKey = locals_js_1.locals.create("chat.closeRequested");
2500
+ /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2501
+ const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2502
+ /** @internal Set once the session row is closed, so the close happens once. */
2503
+ const chatClosePerformedKey = locals_js_1.locals.create("chat.closePerformed");
2504
+ /**
2505
+ * @internal Set once the terminal `.out` record is written. Tracked apart from
2506
+ * {@link chatClosePerformedKey} so a retried close does not emit a second
2507
+ * client-visible event.
2508
+ */
2509
+ const chatCloseRecordWrittenKey = locals_js_1.locals.create("chat.closeRecordWritten");
2470
2510
  /** @internal */
2471
2511
  const chatAgentCompactionKey = locals_js_1.locals.create("chat.agentCompaction");
2472
2512
  /** @internal */
2473
2513
  const chatPendingMessagesKey = locals_js_1.locals.create("chat.pendingMessages");
2474
2514
  /** @internal */
2475
2515
  const chatSteeringQueueKey = locals_js_1.locals.create("chat.steeringQueue");
2516
+ /**
2517
+ * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2518
+ *
2519
+ * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2520
+ * has to append the messages it injects. Without that, an injected message
2521
+ * reaches the model and the browser but no hook, so an app persisting from
2522
+ * `onTurnComplete` never learns it existed.
2523
+ */
2524
+ const chatTurnNewUIMessagesKey = locals_js_1.locals.create("chat.turnNewUIMessages");
2525
+ /**
2526
+ * Steering messages a drain consumed that the model accumulator has not been
2527
+ * given yet.
2528
+ *
2529
+ * The two accumulators are maintained separately, and the model one is
2530
+ * normally advanced by appending each turn's delta. A drained message is
2531
+ * appended to the UI one but reaches the model only through the `prepareStep`
2532
+ * return value, which is per-step: without this the model lane never learns
2533
+ * the message exists and every later turn of the run answers without it,
2534
+ * while the browser, the snapshot and `chat.history.*` all still show it.
2535
+ *
2536
+ * Held as the messages rather than a "rebuild me" flag because the model lane
2537
+ * can only be appended to, never reconstructed. Compaction replaces it with a
2538
+ * summary and deliberately leaves the UI lane whole, so reconverting the UI
2539
+ * lane restores every message the summary replaced.
2540
+ */
2541
+ const chatPendingSteerKey = locals_js_1.locals.create("chat.pendingSteer");
2476
2542
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2477
2543
  const chatInjectedMessageIdsKey = locals_js_1.locals.create("chat.injectedMessageIds");
2478
2544
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2825,20 +2891,32 @@ function chatCompactionStep(options) {
2825
2891
  return result.type === "skipped" ? undefined : result;
2826
2892
  };
2827
2893
  }
2828
- // ---------------------------------------------------------------------------
2829
- // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2830
- // ---------------------------------------------------------------------------
2894
+ const EMPTY_DRAIN = { injected: [], claimed: [] };
2895
+ /**
2896
+ * The model messages to record for one claimed message. Without `prepare`
2897
+ * each entry's own conversion is used. With it, `prepare` returned one list
2898
+ * for the whole batch, so the first claimed message carries all of it and the
2899
+ * rest carry none, which keeps the total exactly what the model received.
2900
+ */
2901
+ function modelFormOf(m, batch, injected) {
2902
+ return batch[0] === m ? injected : [];
2903
+ }
2831
2904
  /**
2832
2905
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2833
2906
  * pending messages. If it returns true, calls `prepareMessages` once to
2834
2907
  * transform the batch, then clears the queue.
2835
- * Returns the model messages to inject (empty if none).
2908
+ * Returns the model messages to inject and the UI messages actually claimed.
2909
+ *
2910
+ * `claimed` is returned rather than only published to locals because each
2911
+ * surface files it somewhere different: `chat.agent` has an accumulator in
2912
+ * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2913
+ * is silently a no-op for any surface that never set the key.
2836
2914
  * @internal
2837
2915
  */
2838
2916
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2839
2917
  const queue = queueOverride ?? locals_js_1.locals.get(chatSteeringQueueKey);
2840
2918
  if (!queue || queue.length === 0)
2841
- return [];
2919
+ return EMPTY_DRAIN;
2842
2920
  const ctx = locals_js_1.locals.get(chatTurnContextKey);
2843
2921
  const stepNumber = steps.length - 1;
2844
2922
  /**
@@ -2861,7 +2939,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2861
2939
  // Call shouldInject once for the whole batch
2862
2940
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2863
2941
  if (!shouldInject)
2864
- return [];
2942
+ return EMPTY_DRAIN;
2865
2943
  const textOfUIMessage = (m) => (m.parts ?? [])
2866
2944
  .filter((p) => p.type === "text")
2867
2945
  .map((p) => p.text)
@@ -2907,7 +2985,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2907
2985
  queue.splice(at, 1);
2908
2986
  }
2909
2987
  if (claimed.length === 0)
2910
- return [];
2988
+ return EMPTY_DRAIN;
2911
2989
  /**
2912
2990
  * Give the claim back if the transform fails. `prepare` is caller code and
2913
2991
  * can throw; the records have already left the router by this point, so
@@ -2937,6 +3015,37 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2937
3015
  for (const m of claimedUIMessages)
2938
3016
  injectedIds.add(m.id);
2939
3017
  }
3018
+ // Record them as part of the conversation.
3019
+ //
3020
+ // The model has them and the browser has them; without this the
3021
+ // accumulator does not, so they reach neither `uiMessages` nor
3022
+ // `newUIMessages` on `onTurnComplete` and an app that persists from there
3023
+ // silently loses the instruction the answer was shaped by. Appending here
3024
+ // rather than at turn end keeps them in the order they happened: after the
3025
+ // message that started the turn, before the response that answers it.
3026
+ //
3027
+ // De-duplicated by id because a step boundary can drain more than once per
3028
+ // turn, and because a message that failed to inject falls back to becoming
3029
+ // its own turn, where it is accumulated the normal way.
3030
+ const currentUIMessages = locals_js_1.locals.get(chatCurrentUIMessagesKey);
3031
+ const turnNew = locals_js_1.locals.get(chatTurnNewUIMessagesKey);
3032
+ for (const m of claimedUIMessages) {
3033
+ if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
3034
+ currentUIMessages.push(m);
3035
+ }
3036
+ if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
3037
+ turnNew.push(m);
3038
+ }
3039
+ }
3040
+ if (claimedUIMessages.length > 0 && currentUIMessages) {
3041
+ const pendingSteer = locals_js_1.locals.get(chatPendingSteerKey) ?? [];
3042
+ for (const m of claimedUIMessages) {
3043
+ if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
3044
+ pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
3045
+ }
3046
+ }
3047
+ locals_js_1.locals.set(chatPendingSteerKey, pendingSteer);
3048
+ }
2940
3049
  // Write injection confirmation chunk to the stream so the frontend
2941
3050
  // knows which messages were injected and where in the response.
2942
3051
  if (injected.length > 0) {
@@ -2978,7 +3087,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2978
3087
  /* non-fatal */
2979
3088
  }
2980
3089
  }
2981
- return injected;
3090
+ return { injected, claimed: claimedUIMessages };
2982
3091
  }, {
2983
3092
  attributes: {
2984
3093
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3204,32 +3313,149 @@ function buildSkillTools(skills) {
3204
3313
  return { loadSkill, readFile, bash };
3205
3314
  }
3206
3315
  /**
3207
- * Returns an options object ready to spread into `streamText()`.
3208
- *
3209
- * Includes `system`, `experimental_telemetry`, and any config fields
3210
- * (temperature, maxTokens, etc.) from the stored prompt.
3211
- *
3212
- * When a `registry` is provided and the prompt has a `model` string,
3213
- * the resolved `LanguageModel` is included as `model`.
3316
+ * A `streamText` with the agent's managed options already applied.
3317
+ *
3318
+ * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3319
+ * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3320
+ * because forgetting the spread silently drops the managed prompt, the skill
3321
+ * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3322
+ * conversational injection.
3323
+ *
3324
+ * Caller options win for everything the caller owns (model, messages, signal,
3325
+ * stopWhen). The three that would otherwise clobber managed behaviour are
3326
+ * merged instead of replaced:
3327
+ *
3328
+ * - `tools` are passed into the helper, so skill tools survive.
3329
+ * - `prepareStep` is composed after the managed one, so a caller's per-step
3330
+ * overrides apply on top of steering and compaction instead of disabling them.
3331
+ *
3332
+ * `system` may be set at the call site, on `chat.agent({ system })`, or
3333
+ * through `chat.prompt.set()`, but only in one of them. Setting it in two
3334
+ * places throws: no shape merges two system values on every supported
3335
+ * version, and dropping one silently is the failure this seam exists to
3336
+ * prevent. Injected instructions append to whichever one is in play.
3337
+ */
3338
+ /**
3339
+ * The agent-level managed options (`registry`, `system`, `cacheControl`,
3340
+ * `systemProviderOptions`), published for the run so that
3341
+ * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3342
+ * `streamText` saw them, and the documented spread form silently ran without
3343
+ * the agent's system prompt or model.
3344
+ */
3345
+ const chatAgentManagedConfigKey = locals_js_1.locals.create("chat.agentManagedConfig");
3346
+ /**
3347
+ * The caller's `streamText` options merged with the agent's managed ones.
3214
3348
  *
3215
- * If no prompt has been set, returns `{}` (no-op spread).
3349
+ * Pure, and separate from the call so it can be asserted directly: everything
3350
+ * the caller did not name has to survive the merge, and the way to be sure of
3351
+ * that is to look at the merged object rather than at what the model received.
3216
3352
  */
3353
+ function buildManagedStreamTextOptions(options, config) {
3354
+ const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3355
+ /**
3356
+ * Only the three keys that collide are intercepted. Everything else, telemetry
3357
+ * included, stays in `rest` and reaches `streamText` untouched, with the
3358
+ * caller's value winning because `rest` is spread after `managed`. Pulling a
3359
+ * key out to "handle" it is how a caller's option gets silently dropped.
3360
+ */
3361
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3362
+ const managed = toStreamTextOptions({
3363
+ registry,
3364
+ system: callerSystem ?? agentSystem,
3365
+ cacheControl,
3366
+ systemProviderOptions,
3367
+ /**
3368
+ * A call site that names `tools` replaces the agent's set rather than
3369
+ * adding to it, so narrowing the tools for one call still works. Omitting
3370
+ * `tools` falls back to the agent's, which is what an `onAction`
3371
+ * regenerate needs: without it a regenerated answer can call nothing.
3372
+ */
3373
+ tools: (tools ?? agentTools),
3374
+ });
3375
+ const promptSystem = locals_js_1.locals.get(chatPromptKey)?.text;
3376
+ /**
3377
+ * Two managed sources conflict too, not only a caller against a managed one.
3378
+ * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3379
+ * would win and `chat.agent({ system })` would go nowhere.
3380
+ */
3381
+ if (promptSystem && agentSystem) {
3382
+ throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3383
+ "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3384
+ "place, and add per-turn context with chat.inject({ role: 'system' }).");
3385
+ }
3386
+ const managedSystem = promptSystem || agentSystem;
3387
+ if (callerSystem !== undefined && managedSystem) {
3388
+ throw new Error("chat.agent: `system` is already set " +
3389
+ (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3390
+ ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3391
+ "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3392
+ }
3393
+ const managedPrepareStep = managed.prepareStep;
3394
+ if (typeof callerPrepareStep === "function") {
3395
+ managed.prepareStep = async (arg) => {
3396
+ const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3397
+ const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3398
+ return { ...(first ?? {}), ...(second ?? {}) };
3399
+ };
3400
+ }
3401
+ return { ...managed, ...rest };
3402
+ }
3403
+ /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3404
+ exports.__buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3405
+ function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3406
+ const bound = (options = {}) => (0, ai_runtime_js_1.streamText)(buildManagedStreamTextOptions(options, {
3407
+ registry,
3408
+ system: agentSystem,
3409
+ cacheControl: agentCacheControl,
3410
+ systemProviderOptions: agentSystemProviderOptions,
3411
+ /** Read per call, so per-turn tools resolved after binding are included. */
3412
+ tools: locals_js_1.locals.get(chatResolvedToolsKey),
3413
+ }));
3414
+ return bound;
3415
+ }
3217
3416
  function toStreamTextOptions(options) {
3417
+ const agentDefaults = locals_js_1.locals.get(chatAgentManagedConfigKey);
3418
+ if (agentDefaults) {
3419
+ options = {
3420
+ registry: agentDefaults.registry,
3421
+ system: agentDefaults.system,
3422
+ cacheControl: agentDefaults.cacheControl,
3423
+ systemProviderOptions: agentDefaults.systemProviderOptions,
3424
+ ...options,
3425
+ };
3426
+ }
3218
3427
  const prompt = locals_js_1.locals.get(chatPromptKey);
3219
3428
  const skills = locals_js_1.locals.get(chatSkillsKey);
3220
3429
  const result = {};
3221
3430
  // Build the combined system prompt: stored prompt + skills preamble.
3222
- const promptText = prompt?.text ?? "";
3431
+ const baseSystem = options?.system;
3432
+ const baseSystemText = typeof baseSystem === "string"
3433
+ ? baseSystem
3434
+ : typeof baseSystem?.content === "string"
3435
+ ? baseSystem.content
3436
+ : "";
3437
+ const promptText = prompt?.text || baseSystemText;
3223
3438
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3224
3439
  if (promptText || skillsText) {
3225
3440
  const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
3226
- // Resolve system-prompt provider options for caching. Precedence (most
3227
- // specific wins, no deep merge): explicit `systemProviderOptions`
3228
- // `cacheControl` sugar `providerOptions` stored on `chat.prompt.set()`.
3441
+ /**
3442
+ * Resolve system-prompt provider options for caching. Precedence, most
3443
+ * specific first and no deep merge: explicit `systemProviderOptions`, the
3444
+ * `cacheControl` sugar, the ones carried on a structured `system` message,
3445
+ * then whatever `chat.prompt.set()` stored.
3446
+ *
3447
+ * A structured `system` counts only when its own text is the one being
3448
+ * sent. When `chat.prompt.set()` supplied the text, its provider options
3449
+ * are the ones that describe it.
3450
+ */
3451
+ const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3452
+ ? baseSystem.providerOptions
3453
+ : undefined;
3229
3454
  const systemProviderOptions = options?.systemProviderOptions ??
3230
3455
  (options?.cacheControl
3231
3456
  ? { anthropic: { cacheControl: options.cacheControl } }
3232
3457
  : undefined) ??
3458
+ baseSystemProviderOptions ??
3233
3459
  locals_js_1.locals.get(chatPromptProviderOptionsKey);
3234
3460
  // A bare string stays a bare string (the unchanged default). With provider
3235
3461
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3238,6 +3464,88 @@ function toStreamTextOptions(options) {
3238
3464
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3239
3465
  : systemText;
3240
3466
  }
3467
+ /**
3468
+ * Append anything injected as system context, in whichever shape the installed
3469
+ * AI SDK accepts.
3470
+ *
3471
+ * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3472
+ * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3473
+ * range still spans all three, so emitting an array unconditionally would break
3474
+ * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3475
+ * a system message inside `messages` that v7 rejects.
3476
+ *
3477
+ * So: concatenate into one string when the base is a plain string, which every
3478
+ * version accepts and which loses nothing (separate blocks only matter for
3479
+ * per-block `providerOptions`). Use the array form only when the base is already
3480
+ * a structured message — that path requires v6+ regardless, because it is how
3481
+ * prompt caching marks the system block, and flattening it would silently throw
3482
+ * the cache away.
3483
+ *
3484
+ * Either way the injected text goes last: the base prompt keeps its position for
3485
+ * caching, and the addition reads as a later amendment. A changed prefix does
3486
+ * cost the first call its cache hit, on turns that actually injected.
3487
+ */
3488
+ /**
3489
+ * Consumed once per turn, not once per read, and moved out of the lane rather
3490
+ * than marked read in place.
3491
+ *
3492
+ * Per turn, because a `run()` that builds options twice (a cheap classifier
3493
+ * pass and then the answer) has to see the injection in both, and draining on
3494
+ * read hands it to whichever call ran first. Moved out, because blocks left in
3495
+ * the lane sit in front of anything injected during the same turn, and
3496
+ * clearing the lane on the next turn then destroys both. Outside a turn there
3497
+ * is no turn to scope the stash to, so the lane drains on read there.
3498
+ */
3499
+ const injectedInstructions = locals_js_1.locals.get(chatInjectedInstructionsKey);
3500
+ const currentTurn = locals_js_1.locals.get(chatTurnContextKey)?.turn;
3501
+ const consumedThisTurn = currentTurn === undefined ? undefined : locals_js_1.locals.get(chatInstructionsConsumedKey);
3502
+ let injectedBlocks = [];
3503
+ if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3504
+ injectedBlocks = consumedThisTurn.blocks;
3505
+ // Anything injected since the stash was taken joins it, so an instruction
3506
+ // added after an action read the lane still reaches the real turn that
3507
+ // shares the action's turn number, rather than the one after.
3508
+ if (injectedInstructions && injectedInstructions.length > 0) {
3509
+ injectedBlocks.push(...injectedInstructions.splice(0));
3510
+ }
3511
+ }
3512
+ else if (injectedInstructions && injectedInstructions.length > 0) {
3513
+ injectedBlocks = injectedInstructions.splice(0);
3514
+ if (currentTurn !== undefined) {
3515
+ locals_js_1.locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3516
+ }
3517
+ }
3518
+ if (injectedBlocks.length > 0) {
3519
+ const blocks = injectedBlocks;
3520
+ const injectedText = blocks
3521
+ .map((block) => (typeof block.content === "string" ? block.content : ""))
3522
+ .filter(Boolean)
3523
+ .join("\n\n");
3524
+ const base = result.system;
3525
+ if (base === undefined) {
3526
+ result.system = injectedText;
3527
+ }
3528
+ else if (typeof base === "string") {
3529
+ result.system = [base, injectedText].filter(Boolean).join("\n\n");
3530
+ }
3531
+ else {
3532
+ // Merged into the existing block rather than added as a second one. An array
3533
+ // of system blocks would keep the base block's cache entry, but ai@5 rejects
3534
+ // it outright ("Invalid prompt: system must be a string") while accepting a
3535
+ // single structured block, and this package's peer range still spans v5.
3536
+ // Choosing per version would mean resolving the installed version at runtime,
3537
+ // which is not something to build on: `import.meta.url` is illegal in this
3538
+ // package's CommonJS output, and a bundled task may have no resolvable `ai`
3539
+ // to read. One shape that works everywhere beats a cache hit.
3540
+ const baseBlock = base;
3541
+ result.system = {
3542
+ ...baseBlock,
3543
+ content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3544
+ .filter(Boolean)
3545
+ .join("\n\n"),
3546
+ };
3547
+ }
3548
+ }
3241
3549
  // Prompt-related options (only if chat.prompt.set() was called)
3242
3550
  if (prompt) {
3243
3551
  // Resolve model via registry if both are present
@@ -3292,7 +3600,7 @@ function toStreamTextOptions(options) {
3292
3600
  }
3293
3601
  // 2. Pending message injection (steering)
3294
3602
  if (taskPendingMessages) {
3295
- const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3603
+ const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3296
3604
  if (injected.length > 0) {
3297
3605
  resultMessages = [...(resultMessages ?? messages), ...injected];
3298
3606
  }
@@ -3302,18 +3610,96 @@ function toStreamTextOptions(options) {
3302
3610
  if (bgQueue && bgQueue.length > 0) {
3303
3611
  const injected = bgQueue.splice(0); // drain
3304
3612
  resultMessages = [...(resultMessages ?? messages), ...injected];
3613
+ const pendingBackground = locals_js_1.locals.get(chatPendingBackgroundKey) ?? [];
3614
+ pendingBackground.push({
3615
+ afterId: locals_js_1.locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
3616
+ messages: injected,
3617
+ });
3618
+ locals_js_1.locals.set(chatPendingBackgroundKey, pendingBackground);
3305
3619
  }
3306
3620
  return resultMessages ? { messages: resultMessages } : undefined;
3307
3621
  };
3308
3622
  }
3309
3623
  return result;
3310
3624
  }
3625
+ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3626
+ /**
3627
+ * Turn the current action into a turn.
3628
+ *
3629
+ * Return it from `onAction` after editing history. The action's own work is
3630
+ * finished first (the edit is applied and snapshotted), then a turn runs on the
3631
+ * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3632
+ * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3633
+ * advances. That gives the answer everything a turn has, the system prompt,
3634
+ * tools, steering, compaction, injected instructions and persistence, with no
3635
+ * action-specific handling.
3636
+ *
3637
+ * @example
3638
+ * ```ts
3639
+ * onAction: async ({ action }) => {
3640
+ * if (action.type === "regenerate") {
3641
+ * chat.history.slice(0, -1);
3642
+ * return chat.turn();
3643
+ * }
3644
+ * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3645
+ * },
3646
+ * ```
3647
+ */
3648
+ function chatTurn() {
3649
+ return { [actionTurnBrand]: true };
3650
+ }
3651
+ function isActionTurn(value) {
3652
+ return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3653
+ }
3654
+ /**
3655
+ * Replace, in a model lane, the run of messages one UI message contributed.
3656
+ *
3657
+ * Used when a UI message is replaced in place (a tool-approval continuation
3658
+ * merging onto the trailing assistant, a captured response reusing an existing
3659
+ * id, a partial replacing an existing message). Reconverting the whole lane
3660
+ * from the UI lane would also replace a compaction summary with the full
3661
+ * transcript and drop the model forms `pendingMessages.prepare` produced.
3662
+ *
3663
+ * The replaced message is the trailing one, so its run is the lane's tail,
3664
+ * before any steer forms appended after it this turn (`tailAfter`). If the
3665
+ * tail does not match the old message's conversion, nothing is changed and
3666
+ * `false` is returned so the caller can fall back to a full reconversion.
3667
+ */
3668
+ async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3669
+ const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3670
+ const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3671
+ // A message that converts to nothing (a pending tool call with no output yet,
3672
+ // which `ignoreIncompleteToolCalls` drops) locates no run in the lane. Matching
3673
+ // an empty slice would splice the new run in without removing what the message
3674
+ // actually contributed, such as a spliced head-start partial, and the lane would
3675
+ // then carry the same tool call twice.
3676
+ if (oldRun.length === 0)
3677
+ return false;
3678
+ const end = lane.length - tailAfter;
3679
+ const start = end - oldRun.length;
3680
+ if (start < 0 || end > lane.length)
3681
+ return false;
3682
+ if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3683
+ return false;
3684
+ lane.splice(start, oldRun.length, ...newRun);
3685
+ return true;
3686
+ }
3311
3687
  function isUIMessageStreamable(value) {
3312
3688
  return (typeof value === "object" &&
3313
3689
  value !== null &&
3314
3690
  "toUIMessageStream" in value &&
3315
3691
  typeof value.toUIMessageStream === "function");
3316
3692
  }
3693
+ const warnedHydrateMessagesDeprecated = new Set();
3694
+ function warnHydrateMessagesDeprecatedOnce(agentId) {
3695
+ if (warnedHydrateMessagesDeprecated.has(agentId))
3696
+ return;
3697
+ warnedHydrateMessagesDeprecated.add(agentId);
3698
+ console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
3699
+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
3700
+ "lets the application own the model's context, with crash recovery and durable " +
3701
+ "compaction that `hydrateMessages` never had.");
3702
+ }
3317
3703
  let warnedMissingOnAction = false;
3318
3704
  function warnMissingOnActionOnce() {
3319
3705
  if (warnedMissingOnAction)
@@ -3446,10 +3832,22 @@ function chatCustomAgent(options) {
3446
3832
  await installChatInputRouter(payload.chatId, {
3447
3833
  resuming: Boolean(payload.continuation),
3448
3834
  });
3835
+ // A custom agent's loop is the customer's, so there is no exit site the
3836
+ // SDK controls. Perform a requested close when `run()` returns, whatever
3837
+ // shape the loop had. Idempotent, so the createSession iterator having
3838
+ // already closed on its own exit costs nothing.
3839
+ const withClose = async (result) => {
3840
+ try {
3841
+ return await result;
3842
+ }
3843
+ finally {
3844
+ await performChatClose();
3845
+ }
3846
+ };
3449
3847
  // Keep the schema-free path identical to the original custom-agent
3450
3848
  // wrapper, including when userRun starts executing.
3451
3849
  if (!parseClientData) {
3452
- return userRun(payload, runOptions);
3850
+ return withClose(userRun(payload, runOptions));
3453
3851
  }
3454
3852
  const isHandoverBoot = payload.trigger === "handover-prepare";
3455
3853
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3465,7 +3863,7 @@ function chatCustomAgent(options) {
3465
3863
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3466
3864
  });
3467
3865
  if (validated.ok) {
3468
- return userRun(validated.payload, runOptions);
3866
+ return withClose(userRun(validated.payload, runOptions));
3469
3867
  }
3470
3868
  if (isHandoverBoot) {
3471
3869
  const signal = await waitForHandover({
@@ -3501,7 +3899,7 @@ function chatCustomAgent(options) {
3501
3899
  sessionId: next.output.sessionId ?? payload.sessionId,
3502
3900
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3503
3901
  };
3504
- return userRun(recoveredPayload, runOptions);
3902
+ return withClose(userRun(recoveredPayload, runOptions));
3505
3903
  },
3506
3904
  });
3507
3905
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3514,7 +3912,19 @@ function chatCustomAgent(options) {
3514
3912
  return task;
3515
3913
  }
3516
3914
  function chatAgent(options) {
3517
- const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, ...restOptions } = options;
3915
+ const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
3916
+ if (hydrateMessages) {
3917
+ if (storage) {
3918
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3919
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3920
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3921
+ }
3922
+ if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
3923
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3924
+ "`loadContext`. Both would own the model's context; keep one.");
3925
+ }
3926
+ warnHydrateMessagesDeprecatedOnce(options.id);
3927
+ }
3518
3928
  const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3519
3929
  const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
3520
3930
  // chat.agent does not expose generic retry options (see docstring on
@@ -3597,6 +4007,42 @@ function chatAgent(options) {
3597
4007
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3598
4008
  // registered) — the wire is delta-only now, no longer a seed.
3599
4009
  let accumulatedMessages = [];
4010
+ /**
4011
+ * Give the model accumulator the background injections a step-boundary
4012
+ * drain handed to the model this turn, and record them for persistence.
4013
+ * Returns how many model messages were appended.
4014
+ */
4015
+ const reconcilePendingBackground = () => {
4016
+ const pending = locals_js_1.locals.get(chatPendingBackgroundKey);
4017
+ if (!pending || pending.length === 0)
4018
+ return 0;
4019
+ locals_js_1.locals.set(chatPendingBackgroundKey, []);
4020
+ let appended = 0;
4021
+ for (const entry of pending) {
4022
+ accumulatedMessages.push(...entry.messages);
4023
+ laneInjections.push(entry);
4024
+ appended += entry.messages.length;
4025
+ }
4026
+ return appended;
4027
+ };
4028
+ /**
4029
+ * Give the model accumulator the steering messages a drain consumed,
4030
+ * in the form the model actually received. Appended, never reconverted
4031
+ * from the UI lane, so a model-only compaction summary survives. Called
4032
+ * on both the success and the error path, before the response or the
4033
+ * partial joins the lane, so the order stays steer-then-answer.
4034
+ */
4035
+ const reconcilePendingSteer = (options) => {
4036
+ const pending = locals_js_1.locals.get(chatPendingSteerKey);
4037
+ if (!pending || pending.length === 0)
4038
+ return [];
4039
+ locals_js_1.locals.set(chatPendingSteerKey, []);
4040
+ for (const entry of pending) {
4041
+ accumulatedMessages.push(...entry.model);
4042
+ options?.turnNew?.push(...entry.model);
4043
+ }
4044
+ return pending;
4045
+ };
3600
4046
  // Accumulated UI messages for persistence. Mirrors the model accumulator
3601
4047
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
3602
4048
  let accumulatedUIMessages = [];
@@ -3614,7 +4060,191 @@ function chatAgent(options) {
3614
4060
  // collectively cost ~600ms on every first-message TTFC. Both reads
3615
4061
  // swallow errors internally; the agent stays available either way.
3616
4062
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
4063
+ const transcriptStorage = storage ??
4064
+ transcriptStorageOverride ??
4065
+ transcriptStorage_js_1.defaultStorage;
4066
+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
4067
+ /**
4068
+ * Who supplies the model's context each turn: the deprecated
4069
+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
4070
+ * the runtime's own transcript.
4071
+ */
4072
+ const loadContextHook = hydrateMessages
4073
+ ? (event) => hydrateMessages(event)
4074
+ : storageLoadContext
4075
+ ? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
4076
+ : undefined;
4077
+ let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
4078
+ let bootTranscriptState = null;
4079
+ /**
4080
+ * True while the model lane holds a compaction summary, so it cannot be
4081
+ * rebuilt from the transcript and has to be persisted as state. Reset
4082
+ * wherever the lane is reconverted from the UI lane.
4083
+ */
4084
+ let laneCompacted = false;
4085
+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
4086
+ let laneInjections = [];
4087
+ let persistedStateSet = false;
3617
4088
  let bootSnapshot;
4089
+ let bootClientData = payload.metadata;
4090
+ if (parseClientData) {
4091
+ try {
4092
+ bootClientData = await parseClientData(payload.metadata);
4093
+ }
4094
+ catch {
4095
+ bootClientData = payload.metadata;
4096
+ }
4097
+ }
4098
+ /**
4099
+ * The `lastOutEventId` the most recent snapshot carried.
4100
+ *
4101
+ * A snapshot written outside a turn — after an action mutates history — has
4102
+ * no turn cursor of its own, and writing `undefined` there would drop the
4103
+ * resume point and make the next boot replay from further back. Retaining it
4104
+ * keeps an action's write cursor-neutral.
4105
+ */
4106
+ let lastSnapshotOutEventId;
4107
+ /**
4108
+ * The `lastInEventId` the most recent snapshot carried.
4109
+ *
4110
+ * A turn-start save happens after the incoming message has been handed to
4111
+ * the turn loop, so the router's live resume floor has already advanced
4112
+ * past it. Persisting that floor before the turn runs would let the next
4113
+ * boot resume past a message this run never answered, which is exactly
4114
+ * what a deferred or recovered message depends on. Turn-start carries
4115
+ * this instead.
4116
+ */
4117
+ let lastSnapshotInEventId;
4118
+ const storageTrigger = (trigger) => trigger === "regenerate-message"
4119
+ ? "regenerate-message"
4120
+ : trigger === "action" || trigger === "action-turn"
4121
+ ? "action"
4122
+ : "submit-message";
4123
+ /**
4124
+ * Hand the runtime's view of the transcript to the storage as a
4125
+ * changeset: the diff against what was last saved, plus the cursors the
4126
+ * next boot resumes from. The shadow only advances when the save
4127
+ * succeeds, so a failed save is folded into the next changeset.
4128
+ */
4129
+ /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
4130
+ let transcriptState = null;
4131
+ let transcriptSaveChain = Promise.resolve();
4132
+ const runSaveTranscript = async (opts) => {
4133
+ const { changes, shadow } = (0, transcriptStorage_js_1.diffTranscript)(transcriptShadow, opts.messages, {
4134
+ nonFinalIds: opts.nonFinalIds,
4135
+ });
4136
+ const throughId = opts.messages.at(-1)?.id ?? "";
4137
+ const queued = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
4138
+ const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
4139
+ ? {
4140
+ v: 1,
4141
+ ...(laneCompacted
4142
+ ? {
4143
+ compaction: {
4144
+ modelMessages: accumulatedMessages,
4145
+ throughId,
4146
+ },
4147
+ }
4148
+ : {}),
4149
+ ...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
4150
+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
4151
+ }
4152
+ : null;
4153
+ if (runtimeState !== null || persistedStateSet) {
4154
+ changes.push({ op: "state", value: runtimeState });
4155
+ }
4156
+ if (opts.skipIfUnchanged && changes.length === 0)
4157
+ return;
4158
+ transcriptState = runtimeState;
4159
+ const liveInCursor = chatInputRouter().resumeFloor();
4160
+ const inCursor = opts.carryInCursor
4161
+ ? lastSnapshotInEventId
4162
+ : liveInCursor !== undefined
4163
+ ? String(liveInCursor)
4164
+ : undefined;
4165
+ await transcriptStorage.save({
4166
+ chatId: payload.chatId,
4167
+ clientData: opts.clientData,
4168
+ turn: opts.turn,
4169
+ trigger: opts.trigger,
4170
+ runId: ctx.run.id,
4171
+ ctx,
4172
+ }, {
4173
+ reason: opts.reason,
4174
+ changes,
4175
+ transcript: {
4176
+ entries: opts.messages.map((message) => ({
4177
+ id: message.id,
4178
+ final: !shadow.nonFinal.has(message.id),
4179
+ message,
4180
+ })),
4181
+ state: transcriptState,
4182
+ },
4183
+ cursors: {
4184
+ lastOutEventId: opts.lastOutEventId,
4185
+ lastInEventId: inCursor,
4186
+ },
4187
+ });
4188
+ transcriptShadow = shadow;
4189
+ lastSnapshotInEventId = inCursor;
4190
+ persistedStateSet = runtimeState !== null;
4191
+ };
4192
+ /**
4193
+ * Serialise every save onto one chain. `runSaveTranscript` derives its
4194
+ * changeset from `transcriptShadow` and only advances it once the write
4195
+ * lands, so two overlapping saves would diff against stale state. The
4196
+ * message list is copied on the way in because the accumulator keeps
4197
+ * mutating while a queued save waits its turn. A rejection is handed to
4198
+ * the caller but never poisons the chain.
4199
+ */
4200
+ const saveTranscript = (opts) => {
4201
+ const queued = { ...opts, messages: [...opts.messages] };
4202
+ const run = () => runSaveTranscript(queued);
4203
+ const next = transcriptSaveChain.then(run, run);
4204
+ transcriptSaveChain = next.then(() => undefined, () => undefined);
4205
+ return next;
4206
+ };
4207
+ /**
4208
+ * Persist the accumulator outside a turn.
4209
+ *
4210
+ * An action is not a turn, so it never reaches the turn-complete path where
4211
+ * the transcript is normally saved, but a `chat.history` mutation changes
4212
+ * the conversation and has to survive the run ending.
4213
+ *
4214
+ * Cursor-neutral: an action has no turn cursor of its own, and writing
4215
+ * `undefined` would drop the resume point the last turn established and make
4216
+ * the next boot replay from further back.
4217
+ */
4218
+ const writeSnapshotOutsideTurn = async (reason, turnContext) => {
4219
+ if (hydrateMessages)
4220
+ return;
4221
+ try {
4222
+ await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
4223
+ await saveTranscript({
4224
+ reason: "action",
4225
+ messages: accumulatedUIMessages,
4226
+ turn: turnContext.turn,
4227
+ trigger: "action",
4228
+ clientData: turnContext.clientData,
4229
+ lastOutEventId: lastSnapshotOutEventId,
4230
+ });
4231
+ }, {
4232
+ attributes: {
4233
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4234
+ [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
4235
+ "chat.snapshot.reason": reason,
4236
+ "chat.messages.count": accumulatedUIMessages.length,
4237
+ },
4238
+ });
4239
+ }
4240
+ catch (error) {
4241
+ v3_1.logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4242
+ error: error instanceof Error ? error.message : String(error),
4243
+ sessionId: sessionIdForSnapshot,
4244
+ reason,
4245
+ });
4246
+ }
4247
+ };
3618
4248
  let replayedSettled = [];
3619
4249
  let replayedPartial;
3620
4250
  let replayedPartialRaw;
@@ -3624,13 +4254,20 @@ function chatAgent(options) {
3624
4254
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
3625
4255
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
3626
4256
  const bootInjectedQueue = [];
4257
+ const recoveredSeqByPayload = new WeakMap();
4258
+ const dispatchBootInjected = () => bootInjectedQueue.shift();
4259
+ const settleRecoveredTurn = (wirePayload) => {
4260
+ const settledSeq = recoveredSeqByPayload.get(wirePayload);
4261
+ if (settledSeq !== undefined)
4262
+ chatInputRouter().settleRecovered(settledSeq);
4263
+ };
3627
4264
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
3628
4265
  // `.in` resume cursor, computed at most once per boot. The boot
3629
4266
  // block below resolves it (snapshot field or records scan) and the
3630
4267
  // resume-cursor block reuses it instead of re-scanning.
3631
4268
  let bootInCursor;
3632
4269
  let bootInCursorResolved = false;
3633
- if (!hydrateMessages && couldHavePriorState) {
4270
+ if (couldHavePriorState) {
3634
4271
  // Single parent span for the whole boot read phase — snapshot
3635
4272
  // read, session.out replay, session.in replay. Per-phase timing
3636
4273
  // + result counts are attributes on the span.
@@ -3638,18 +4275,33 @@ function chatAgent(options) {
3638
4275
  // snapshot read
3639
4276
  const snapStart = Date.now();
3640
4277
  try {
3641
- bootSnapshot = await readChatSnapshot(sessionIdForSnapshot);
4278
+ const loaded = hydrateMessages
4279
+ ? undefined
4280
+ : await transcriptStorage.load({
4281
+ chatId: payload.chatId,
4282
+ clientData: bootClientData,
4283
+ });
4284
+ if (loaded) {
4285
+ transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)(loaded.messages, new Set(loaded.nonFinalIds ?? []));
4286
+ bootTranscriptState = loaded.state;
4287
+ transcriptState = loaded.state ?? null;
4288
+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
4289
+ bootSnapshot = {
4290
+ messages: loaded.messages,
4291
+ lastOutEventId: loaded.cursors?.lastOutEventId,
4292
+ lastInEventId: loaded.cursors?.lastInEventId,
4293
+ };
4294
+ }
3642
4295
  }
3643
4296
  catch (error) {
3644
- // `readChatSnapshot` already swallows + warns internally; this catch
3645
- // is just belt-and-suspenders against tracer/span errors.
3646
- v3_1.logger.warn("chat.agent: snapshot read failed; continuing without snapshot", {
4297
+ v3_1.logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
3647
4298
  error: error instanceof Error ? error.message : String(error),
3648
4299
  sessionId: sessionIdForSnapshot,
3649
4300
  });
3650
4301
  }
3651
4302
  bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
3652
- bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot);
4303
+ bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
4304
+ (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
3653
4305
  bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
3654
4306
  // Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
3655
4307
  // id of the previous turn's `turn-complete` control record). The
@@ -3657,6 +4309,8 @@ function chatAgent(options) {
3657
4309
  // Without seeding, the new worker would emit no trim on its first
3658
4310
  // turn (chain self-bootstraps from turn 2), so this is purely an
3659
4311
  // optimization to keep continuation runs bounded from the first turn.
4312
+ lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4313
+ lastSnapshotInEventId = bootSnapshot?.lastInEventId;
3660
4314
  if (bootSnapshot?.lastOutEventId !== undefined) {
3661
4315
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
3662
4316
  if (Number.isFinite(seeded)) {
@@ -3748,16 +4402,14 @@ function chatAgent(options) {
3748
4402
  }
3749
4403
  // ── session.in router ──────────────────────────────────────────
3750
4404
  //
3751
- // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3752
- // only a fallback: the boot block above may already have resolved a
3753
- // cursor from the snapshot, which is used when the boundary itself
3754
- // carries none.
4405
+ const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
3755
4406
  await installChatInputRouter(payload.chatId, {
3756
4407
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4408
+ recoveredSeqNums,
3757
4409
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3758
4410
  });
3759
4411
  // ── Recovery boot + chain reconstruction ────────────────────────
3760
- if (!hydrateMessages) {
4412
+ {
3761
4413
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
3762
4414
  const inFlightUsers = replayedInTail.map((r) => r.message);
3763
4415
  const partialAssistant = replayedPartial;
@@ -3836,7 +4488,7 @@ function chatAgent(options) {
3836
4488
  // branches: at n=1 the orphan partial is dropped and the interrupted
3837
4489
  // user is re-dispatched as a fresh turn instead.
3838
4490
  let seedChain;
3839
- let recoveredTurns;
4491
+ let recoveredEntries;
3840
4492
  if (hookChain !== undefined) {
3841
4493
  seedChain = hookChain;
3842
4494
  }
@@ -3847,13 +4499,26 @@ function chatAgent(options) {
3847
4499
  seedChain = settledMessages;
3848
4500
  }
3849
4501
  if (hookRecoveredTurns !== undefined) {
3850
- recoveredTurns = hookRecoveredTurns;
4502
+ const seqNumsByRecoveredId = new Map();
4503
+ for (const entry of replayedInTail) {
4504
+ const existing = seqNumsByRecoveredId.get(entry.message.id);
4505
+ if (existing)
4506
+ existing.push(entry.seqNum);
4507
+ else
4508
+ seqNumsByRecoveredId.set(entry.message.id, [entry.seqNum]);
4509
+ }
4510
+ recoveredEntries = hookRecoveredTurns.map((message) => ({
4511
+ message,
4512
+ seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4513
+ }));
3851
4514
  }
3852
4515
  else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3853
- recoveredTurns = inFlightUsers.slice(1);
4516
+ recoveredEntries = replayedInTail
4517
+ .slice(1)
4518
+ .map((r) => ({ message: r.message, seqNum: r.seqNum }));
3854
4519
  }
3855
4520
  else {
3856
- recoveredTurns = inFlightUsers;
4521
+ recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum }));
3857
4522
  }
3858
4523
  // `beforeBoot` errors bubble — the customer opted into blocking
3859
4524
  // persistence and a failure there should fail the run rather than
@@ -3861,16 +4526,6 @@ function chatAgent(options) {
3861
4526
  if (hookBeforeBoot) {
3862
4527
  await hookBeforeBoot();
3863
4528
  }
3864
- // Advance the session.in cursor past every recovered user so
3865
- // the live subscription doesn't re-deliver them.
3866
- if (replayedInTail.length > 0) {
3867
- const lastRecoveredSeq = replayedInTail[replayedInTail.length - 1].seqNum;
3868
- const currentCursor = v3_1.sessionStreams.lastSeqNum(payload.chatId, "in");
3869
- if (currentCursor === undefined || lastRecoveredSeq > currentCursor) {
3870
- v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", lastRecoveredSeq);
3871
- v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", lastRecoveredSeq);
3872
- }
3873
- }
3874
4529
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
3875
4530
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
3876
4531
  // dispatch as normal turns with the existing hook stack.
@@ -3893,13 +4548,14 @@ function chatAgent(options) {
3893
4548
  for (const entry of replayedInTail) {
3894
4549
  metadataById.set(entry.message.id, entry.metadata);
3895
4550
  }
3896
- for (const msg of recoveredTurns) {
4551
+ const dispatchedRecoveredSeqs = new Set();
4552
+ for (const { message: msg, seqNum } of recoveredEntries) {
3897
4553
  if (wireMessageId && msg.id === wireMessageId)
3898
4554
  continue;
3899
4555
  const recoveredMetadata = metadataById.has(msg.id)
3900
4556
  ? metadataById.get(msg.id)
3901
4557
  : payload.metadata;
3902
- bootInjectedQueue.push({
4558
+ const injectedPayload = {
3903
4559
  chatId: payload.chatId,
3904
4560
  sessionId: payload.sessionId,
3905
4561
  metadata: recoveredMetadata,
@@ -3908,7 +4564,17 @@ function chatAgent(options) {
3908
4564
  messageId: msg.id,
3909
4565
  continuation: payload.continuation,
3910
4566
  previousRunId: payload.previousRunId,
3911
- });
4567
+ };
4568
+ bootInjectedQueue.push(injectedPayload);
4569
+ if (seqNum !== undefined) {
4570
+ recoveredSeqByPayload.set(injectedPayload, seqNum);
4571
+ dispatchedRecoveredSeqs.add(seqNum);
4572
+ }
4573
+ }
4574
+ for (const entry of replayedInTail) {
4575
+ if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4576
+ chatInputRouter().settleRecovered(entry.seqNum);
4577
+ }
3912
4578
  }
3913
4579
  accumulatedUIMessages = seedChain;
3914
4580
  // ── Head-start bootstrap ─────────────────────────────────────
@@ -3920,7 +4586,8 @@ function chatAgent(options) {
3920
4586
  // path where wire-borne UIMessage[] still seeds the accumulator,
3921
4587
  // and it's safe because the route handler isn't subject to the
3922
4588
  // `/in/append` 512 KiB cap.
3923
- if (accumulatedUIMessages.length === 0 &&
4589
+ if (!loadContextHook &&
4590
+ accumulatedUIMessages.length === 0 &&
3924
4591
  payload.trigger === "handover-prepare" &&
3925
4592
  Array.isArray(payload.headStartMessages) &&
3926
4593
  payload.headStartMessages.length > 0) {
@@ -3949,7 +4616,17 @@ function chatAgent(options) {
3949
4616
  }
3950
4617
  }
3951
4618
  try {
3952
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
4619
+ const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
4620
+ const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4621
+ accumulatedMessages = restored.messages;
4622
+ laneCompacted = restored.compacted;
4623
+ laneInjections = restored.injections;
4624
+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4625
+ locals_js_1.locals.set(chatBackgroundQueueKey, [
4626
+ ...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
4627
+ ...bootRuntimeState.queued,
4628
+ ]);
4629
+ }
3953
4630
  }
3954
4631
  catch (error) {
3955
4632
  v3_1.logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
@@ -3963,6 +4640,12 @@ function chatAgent(options) {
3963
4640
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
3964
4641
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3965
4642
  }
4643
+ locals_js_1.locals.set(chatAgentManagedConfigKey, {
4644
+ registry: promptRegistry,
4645
+ system: agentSystem,
4646
+ cacheControl: agentCacheControl,
4647
+ systemProviderOptions: agentSystemProviderOptions,
4648
+ });
3966
4649
  // Token usage tracking across turns
3967
4650
  let previousTurnUsage;
3968
4651
  let cumulativeUsage = emptyUsage();
@@ -4051,7 +4734,7 @@ function chatAgent(options) {
4051
4734
  */
4052
4735
  let dispatchedRecoveredFirstTurn = false;
4053
4736
  if (preloaded && bootInjectedQueue.length > 0) {
4054
- currentWirePayload = bootInjectedQueue.shift();
4737
+ currentWirePayload = dispatchBootInjected();
4055
4738
  dispatchedRecoveredFirstTurn = true;
4056
4739
  }
4057
4740
  // Handle preloaded runs — fire onPreload, then wait for the first real message
@@ -4259,7 +4942,7 @@ function chatAgent(options) {
4259
4942
  // waiting on the live session.in. Subsequent recovered turns
4260
4943
  // get drained by the end-of-turn picker below.
4261
4944
  if (bootInjectedQueue.length > 0) {
4262
- currentWirePayload = bootInjectedQueue.shift();
4945
+ currentWirePayload = dispatchBootInjected();
4263
4946
  }
4264
4947
  else {
4265
4948
  const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds;
@@ -4320,6 +5003,7 @@ function chatAgent(options) {
4320
5003
  } // end else (no boot-injected first turn)
4321
5004
  }
4322
5005
  for (let turn = 0; turn < maxTurns; turn++) {
5006
+ let turnClientData = payload.metadata;
4323
5007
  // Declared here so the finally can detach it — a handler leaked past
4324
5008
  // its turn duplicates every mid-stream message into the shared buffer.
4325
5009
  let turnMsgSub;
@@ -4341,6 +5025,7 @@ function chatAgent(options) {
4341
5025
  // `cleanupAbortedParts` no longer has to be re-applied below.
4342
5026
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4343
5027
  const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
5028
+ turnClientData = clientData;
4344
5029
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
4345
5030
  // Actions are not turns. They use a different span name
4346
5031
  // and don't carry a turn.number. Branched on at `isAction`.
@@ -4375,8 +5060,10 @@ function chatAgent(options) {
4375
5060
  // (errors are caught by the outer try/catch which writes an error chunk)
4376
5061
  locals_js_1.locals.set(chatPipeCountKey, 0);
4377
5062
  locals_js_1.locals.set(chatDeferKey, new Set());
5063
+ locals_js_1.locals.set(chatOutGateKey, createChatOutGate());
4378
5064
  locals_js_1.locals.set(chatCompactionStateKey, undefined);
4379
5065
  locals_js_1.locals.set(chatSteeringQueueKey, []);
5066
+ locals_js_1.locals.set(chatPendingBackgroundKey, []);
4380
5067
  locals_js_1.locals.set(chatResponsePartsKey, []);
4381
5068
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
4382
5069
  // by deferred work from the previous turn's onTurnComplete need to
@@ -4458,6 +5145,10 @@ function chatAgent(options) {
4458
5145
  // Track new messages for this turn (user input + assistant response).
4459
5146
  const turnNewModelMessages = [];
4460
5147
  const turnNewUIMessages = [];
5148
+ locals_js_1.locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
5149
+ // A head-start handover deliberately resumes from an assistant
5150
+ // message it spliced in, so it isn't a no-op turn.
5151
+ let splicedHandoverPartial = false;
4461
5152
  // ── Action handling ──────────────────────────────────────
4462
5153
  // Actions arrive on the same input stream but with
4463
5154
  // trigger === "action". They are NOT turns — only
@@ -4468,16 +5159,25 @@ function chatAgent(options) {
4468
5159
  // an action, return a `StreamTextResult` (auto-piped),
4469
5160
  // string, or UIMessage from `onAction`. Turn counter
4470
5161
  // does not advance.
4471
- let actionStreamResult = undefined;
5162
+ let actionResult = undefined;
5163
+ /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
5164
+ let actionTurn = false;
5165
+ /**
5166
+ * Whether this action changed the conversation, by rolling history
5167
+ * back or by streaming a response. Drives the single snapshot write
5168
+ * at the end — an action never reaches the turn-complete path that
5169
+ * normally does it.
5170
+ */
5171
+ let actionChangedHistory = false;
4472
5172
  if (isAction) {
4473
5173
  // Parse and validate the action payload
4474
5174
  const parsedAction = parseAction
4475
5175
  ? await parseAction(currentWirePayload.action)
4476
5176
  : currentWirePayload.action;
4477
5177
  // Hydrate messages from backend if configured
4478
- if (hydrateMessages) {
5178
+ if (loadContextHook) {
4479
5179
  const hydrated = await tracer_js_1.tracer.startActiveSpan("hydrateMessages()", async () => {
4480
- return hydrateMessages({
5180
+ return loadContextHook({
4481
5181
  chatId: currentWirePayload.chatId,
4482
5182
  turn,
4483
5183
  trigger: "action",
@@ -4497,12 +5197,14 @@ function chatAgent(options) {
4497
5197
  });
4498
5198
  accumulatedUIMessages = [...hydrated];
4499
5199
  accumulatedMessages = await toModelMessages(hydrated);
5200
+ laneCompacted = false;
5201
+ laneInjections = [];
4500
5202
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4501
5203
  }
4502
5204
  // Fire onAction — handler may mutate state via
4503
5205
  // `chat.history.*` and / or return a model response.
4504
5206
  if (onAction) {
4505
- actionStreamResult = await tracer_js_1.tracer.startActiveSpan("onAction()", async () => {
5207
+ actionResult = await tracer_js_1.tracer.startActiveSpan("onAction()", async () => {
4506
5208
  return await onAction({
4507
5209
  action: parsedAction,
4508
5210
  chatId: currentWirePayload.chatId,
@@ -4527,7 +5229,10 @@ function chatAgent(options) {
4527
5229
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
4528
5230
  accumulatedUIMessages = [...actionOverride];
4529
5231
  accumulatedMessages = await toModelMessages(actionOverride);
5232
+ laneCompacted = false;
5233
+ laneInjections = [];
4530
5234
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5235
+ actionChangedHistory = true;
4531
5236
  }
4532
5237
  }
4533
5238
  else {
@@ -4548,7 +5253,7 @@ function chatAgent(options) {
4548
5253
  // route handler's first-turn history to the hydrate hook as
4549
5254
  // incoming messages instead (gated on the pending handover).
4550
5255
  if (turn === 0 &&
4551
- hydrateMessages &&
5256
+ loadContextHook &&
4552
5257
  cleanedUIMessages.length === 0 &&
4553
5258
  (locals_js_1.locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
4554
5259
  Array.isArray(payload.headStartMessages) &&
@@ -4578,7 +5283,7 @@ function chatAgent(options) {
4578
5283
  },
4579
5284
  }));
4580
5285
  }
4581
- if (hydrateMessages) {
5286
+ if (loadContextHook) {
4582
5287
  // Snapshot the ids the accumulator knew BEFORE this
4583
5288
  // turn ran — used below to decide whether an
4584
5289
  // incoming wire message is genuinely new or just a
@@ -4596,7 +5301,7 @@ function chatAgent(options) {
4596
5301
  // was always true for `submit-message` is now true for every
4597
5302
  // trigger.
4598
5303
  const hydrated = await tracer_js_1.tracer.startActiveSpan("hydrateMessages()", async () => {
4599
- return hydrateMessages({
5304
+ return loadContextHook({
4600
5305
  chatId: currentWirePayload.chatId,
4601
5306
  turn,
4602
5307
  trigger: currentWirePayload.trigger,
@@ -4635,6 +5340,8 @@ function chatAgent(options) {
4635
5340
  }
4636
5341
  accumulatedUIMessages = merged;
4637
5342
  accumulatedMessages = await toModelMessages(merged);
5343
+ laneCompacted = false;
5344
+ laneInjections = [];
4638
5345
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4639
5346
  // Track new messages for onTurnComplete.newUIMessages.
4640
5347
  // Only push for genuinely new ids — HITL continuations
@@ -4680,6 +5387,8 @@ function chatAgent(options) {
4680
5387
  accumulatedUIMessages.pop();
4681
5388
  }
4682
5389
  accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5390
+ laneCompacted = false;
5391
+ laneInjections = [];
4683
5392
  }
4684
5393
  else if (cleanedUIMessages.length > 0) {
4685
5394
  // Submit-message (and the special-cased
@@ -4696,6 +5405,7 @@ function chatAgent(options) {
4696
5405
  // where AI SDK regenerates the id (TRI-9137) still
4697
5406
  // applies via `rewriteIncomingIdViaToolCallMap`.
4698
5407
  let replaced = false;
5408
+ const replacedPairs = [];
4699
5409
  for (const raw of cleanedUIMessages) {
4700
5410
  let incoming = raw;
4701
5411
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -4707,7 +5417,9 @@ function chatAgent(options) {
4707
5417
  }
4708
5418
  }
4709
5419
  if (idx !== -1) {
4710
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5420
+ const previous = accumulatedUIMessages[idx];
5421
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5422
+ replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4711
5423
  replaced = true;
4712
5424
  }
4713
5425
  else {
@@ -4717,9 +5429,19 @@ function chatAgent(options) {
4717
5429
  recordToolCallIdsFromMessage(incoming);
4718
5430
  }
4719
5431
  if (replaced) {
4720
- // Replacement changes structure — reconvert all model
4721
- // messages instead of appending.
4722
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5432
+ let inPlace = true;
5433
+ for (const { previous, merged } of replacedPairs) {
5434
+ if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5435
+ inPlace = false;
5436
+ break;
5437
+ }
5438
+ }
5439
+ if (!inPlace) {
5440
+ v3_1.logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5441
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5442
+ laneCompacted = false;
5443
+ laneInjections = [];
5444
+ }
4723
5445
  }
4724
5446
  else {
4725
5447
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -4762,10 +5484,34 @@ function chatAgent(options) {
4762
5484
  messageId: locals_js_1.locals.get(chatHandoverMessageIdKey),
4763
5485
  });
4764
5486
  locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
5487
+ splicedHandoverPartial = true;
4765
5488
  }
4766
5489
  }
4767
5490
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4768
5491
  } // end if (trigger !== "action")
5492
+ // ── No-op turn ──────────────────────────────────────────
5493
+ //
5494
+ // A submit that added no new user message and leaves the model
5495
+ // chain ending on an assistant message has nothing to answer —
5496
+ // calling the model would prefill its own last reply. Keyed on
5497
+ // the model tail, so a `tool`-terminated chain (a merged tool
5498
+ // approval) still runs.
5499
+ const isNoOpTurn = !isAction &&
5500
+ !splicedHandoverPartial &&
5501
+ currentWirePayload.trigger === "submit-message" &&
5502
+ turnNewUIMessages.length === 0 &&
5503
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5504
+ if (isNoOpTurn) {
5505
+ msgSub?.off();
5506
+ v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
5507
+ chatId: currentWirePayload.chatId,
5508
+ messageId: currentWirePayload.messageId,
5509
+ });
5510
+ settleRecoveredTurn(currentWirePayload);
5511
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5512
+ // Not a turn — don't consume an iteration.
5513
+ turn--;
5514
+ }
4769
5515
  // ── Action result handling ──────────────────────────────
4770
5516
  // For action turns, skip the turn machinery entirely.
4771
5517
  // If `onAction` returned a stream / string / UIMessage,
@@ -4775,34 +5521,51 @@ function chatAgent(options) {
4775
5521
  // The turn counter is decremented so the next iteration
4776
5522
  // sees the same `turn` value — actions don't count.
4777
5523
  if (isAction) {
4778
- msgSub?.off();
4779
- if ((locals_js_1.locals.get(chatPipeCountKey) ?? 0) === 0 &&
4780
- isUIMessageStreamable(actionStreamResult)) {
4781
- try {
4782
- const resolvedOptions = resolveUIMessageStreamOptions();
4783
- const uiStream = actionStreamResult.toUIMessageStream({
4784
- ...resolvedOptions,
4785
- generateMessageId: resolvedOptions.generateMessageId ?? ai_runtime_js_1.generateId,
4786
- });
4787
- await pipeChat(uiStream, {
4788
- signal: combinedSignal,
4789
- spanName: "stream response",
4790
- });
5524
+ if (isActionTurn(actionResult)) {
5525
+ // Persist the edit before the turn starts, so a turn that is
5526
+ // cancelled or runs out of memory continues from the edited
5527
+ // history rather than from the snapshot the edit replaced.
5528
+ // The turn then does its own hooks, completion and snapshot.
5529
+ if (actionChangedHistory) {
5530
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4791
5531
  }
4792
- catch (error) {
4793
- if (error instanceof Error &&
4794
- error.name === "AbortError" &&
4795
- runSignal.aborted) {
4796
- return "exit";
4797
- }
4798
- throw error;
5532
+ actionTurn = true;
5533
+ }
5534
+ else if (actionResult !== undefined) {
5535
+ throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5536
+ "after the edit, return chat.turn() and a turn runs on the edited history. " +
5537
+ "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5538
+ }
5539
+ else {
5540
+ msgSub?.off();
5541
+ if (actionChangedHistory) {
5542
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4799
5543
  }
5544
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5545
+ // Don't consume a turn iteration — actions aren't turns.
5546
+ turn--;
4800
5547
  }
4801
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4802
- // Don't consume a turn iteration — actions aren't turns.
4803
- turn--;
4804
5548
  }
4805
- if (!isAction) {
5549
+ // A no-op turn skips this block, and with it `followSessionPin`:
5550
+ // there is nothing to answer, so nothing to hand over.
5551
+ if ((!isAction || actionTurn) && !isNoOpTurn) {
5552
+ if (!hydrateMessages) {
5553
+ chatDeferBeforeOutput(saveTranscript({
5554
+ reason: "turn-start",
5555
+ messages: accumulatedUIMessages,
5556
+ turn,
5557
+ trigger: storageTrigger(currentWirePayload.trigger),
5558
+ clientData,
5559
+ lastOutEventId: lastSnapshotOutEventId,
5560
+ skipIfUnchanged: true,
5561
+ carryInCursor: true,
5562
+ }).catch((error) => {
5563
+ v3_1.logger.warn("chat.agent: turn-start transcript write failed; a reload mid-answer may not show the message being answered", {
5564
+ error: error instanceof Error ? error.message : String(error),
5565
+ sessionId: sessionIdForSnapshot,
5566
+ });
5567
+ }));
5568
+ }
4806
5569
  // Mint a scoped public access token once per turn, reused for
4807
5570
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
4808
5571
  const currentRunId = ctx.run.id;
@@ -4891,6 +5654,8 @@ function chatAgent(options) {
4891
5654
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
4892
5655
  accumulatedUIMessages = [...turnStartOverride];
4893
5656
  accumulatedMessages = await toModelMessages(turnStartOverride);
5657
+ laneCompacted = false;
5658
+ laneInjections = [];
4894
5659
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4895
5660
  }
4896
5661
  }, {
@@ -4907,9 +5672,11 @@ function chatAgent(options) {
4907
5672
  },
4908
5673
  });
4909
5674
  }
5675
+ await followSessionPin(currentWirePayload.chatId, versionSkew);
4910
5676
  // chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
4911
- // skip run() and signal the transport to re-trigger the same message
4912
- // on the new version.
5677
+ // skip run() and hand over to a fresh run on the new version. The
5678
+ // successor picks the message up off session.in; the transport only
5679
+ // keeps reading.
4913
5680
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
4914
5681
  await writeUpgradeRequiredChunk();
4915
5682
  return "exit";
@@ -4949,7 +5716,12 @@ function chatAgent(options) {
4949
5716
  const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
4950
5717
  const bgQueue = locals_js_1.locals.get(chatBackgroundQueueKey);
4951
5718
  if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
4952
- accumulatedMessages.push(...bgQueue.splice(0));
5719
+ const injected = bgQueue.splice(0);
5720
+ accumulatedMessages.push(...injected);
5721
+ laneInjections.push({
5722
+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
5723
+ messages: injected,
5724
+ });
4953
5725
  }
4954
5726
  if (isHeadStartFinalTurn) {
4955
5727
  // The synthesized partial UIMessage IS the response.
@@ -4968,6 +5740,9 @@ function chatAgent(options) {
4968
5740
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
4969
5741
  runResult = await userRun({
4970
5742
  ...restWire,
5743
+ // A turn requested by chat.turn() is not the action itself:
5744
+ // a run() that short-circuits on "action" must still answer.
5745
+ ...(actionTurn ? { trigger: "action-turn" } : {}),
4971
5746
  messages: preparedMessages,
4972
5747
  clientData,
4973
5748
  continuation,
@@ -4980,6 +5755,7 @@ function chatAgent(options) {
4980
5755
  signal: combinedSignal,
4981
5756
  cancelSignal,
4982
5757
  stopSignal,
5758
+ streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
4983
5759
  });
4984
5760
  }
4985
5761
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5093,7 +5869,21 @@ function chatAgent(options) {
5093
5869
  if (runOverride) {
5094
5870
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5095
5871
  accumulatedUIMessages = [...runOverride];
5096
- accumulatedMessages = await toModelMessages(runOverride);
5872
+ /**
5873
+ * Steers the drain consumed are left out of the rebuild and
5874
+ * appended by the reconciliation below instead, so the lane
5875
+ * gets the form the model actually received rather than a
5876
+ * reconversion of the UI message, and gets it once. A steer
5877
+ * the edit removed is dropped from the pending list too, so
5878
+ * the edit is honoured.
5879
+ */
5880
+ const overrideIds = new Set(runOverride.map((m) => m.id));
5881
+ const pending = (locals_js_1.locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5882
+ locals_js_1.locals.set(chatPendingSteerKey, pending);
5883
+ const pendingIds = new Set(pending.map((e) => e.ui.id));
5884
+ accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5885
+ laneCompacted = false;
5886
+ laneInjections = [];
5097
5887
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5098
5888
  }
5099
5889
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5116,6 +5906,8 @@ function chatAgent(options) {
5116
5906
  accumulatedMessages = taskCompactionConfig?.compactModelMessages
5117
5907
  ? await taskCompactionConfig.compactModelMessages(compactEvent)
5118
5908
  : modelOnlyOverride;
5909
+ laneCompacted = true;
5910
+ laneInjections = [];
5119
5911
  // Apply UI messages: callback or default (preserve all)
5120
5912
  if (taskCompactionConfig?.compactUIMessages) {
5121
5913
  accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
@@ -5123,10 +5915,20 @@ function chatAgent(options) {
5123
5915
  }
5124
5916
  // Determine if the user stopped generation this turn (not a full run cancel).
5125
5917
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5918
+ // Give the model accumulator the steering messages the drain
5919
+ // consumed. Appended, never reconverted from the UI lane, so a
5920
+ // model-only compaction summary set just above survives; and done
5921
+ // before the response is appended so the order stays
5922
+ // steer-then-answer. Outside the `capturedResponseMessage`
5923
+ // branches below, so a turn that captured no response is covered.
5924
+ const steerTailThisTurn = reconcilePendingSteer({
5925
+ turnNew: turnNewModelMessages,
5926
+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
5126
5927
  // Append the assistant's response (partial or complete) to the accumulator.
5127
5928
  // The onFinish callback fires even on abort/stop, so partial responses
5128
5929
  // from stopped generation are captured correctly.
5129
5930
  let rawResponseMessage;
5931
+ let responseWasSkipped = false;
5130
5932
  if (capturedResponseMessage) {
5131
5933
  // Keep the raw message before cleanup for users who want custom handling
5132
5934
  rawResponseMessage = capturedResponseMessage;
@@ -5152,43 +5954,56 @@ function chatAgent(options) {
5152
5954
  };
5153
5955
  locals_js_1.locals.set(chatResponsePartsKey, []);
5154
5956
  }
5155
- // Tool-approval continuations: the AI SDK reuses the trailing
5156
- // assistant's ID (via originalMessages) so the captured response
5157
- // carries the same ID as an existing message. Replace in place
5158
- // instead of pushing a duplicate. For action turns this never
5159
- // matches because originalMessages is omitted (fresh ID).
5160
- const existingIdx = capturedResponseMessage.id
5161
- ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5162
- : -1;
5163
- if (existingIdx !== -1) {
5164
- accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5165
- }
5166
- else {
5167
- accumulatedUIMessages.push(capturedResponseMessage);
5168
- }
5169
- turnNewUIMessages.push(capturedResponseMessage);
5170
- locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5171
- // Record toolCallId → head messageId so a HITL
5172
- // continuation next turn can recover the head id
5173
- // even if the AI SDK regenerates it. See
5174
- // `chatToolCallToMessageIdKey` for the full
5175
- // rationale (TRI-9137).
5176
- recordToolCallIdsFromMessage(capturedResponseMessage);
5177
- try {
5178
- const responseModelMessages = await toModelMessages([
5179
- stripProviderMetadata(capturedResponseMessage),
5180
- ]);
5957
+ const responseHasContent = capturedResponseMessage.parts.some((part) => part.type !== "step-start");
5958
+ if (responseHasContent) {
5959
+ // Tool-approval continuations: the AI SDK reuses the trailing
5960
+ // assistant's ID (via originalMessages) so the captured response
5961
+ // carries the same ID as an existing message. Replace in place
5962
+ // instead of pushing a duplicate. For action turns this never
5963
+ // matches because originalMessages is omitted (fresh ID).
5964
+ const existingIdx = capturedResponseMessage.id
5965
+ ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5966
+ : -1;
5967
+ const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5181
5968
  if (existingIdx !== -1) {
5182
- // Reconvert all model messages since we replaced rather than appended
5183
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5969
+ accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5184
5970
  }
5185
5971
  else {
5186
- accumulatedMessages.push(...responseModelMessages);
5972
+ accumulatedUIMessages.push(capturedResponseMessage);
5973
+ }
5974
+ turnNewUIMessages.push(capturedResponseMessage);
5975
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5976
+ // Record toolCallId → head messageId so a HITL
5977
+ // continuation next turn can recover the head id
5978
+ // even if the AI SDK regenerates it. See
5979
+ // `chatToolCallToMessageIdKey` for the full
5980
+ // rationale (TRI-9137).
5981
+ recordToolCallIdsFromMessage(capturedResponseMessage);
5982
+ try {
5983
+ const responseModelMessages = await toModelMessages([
5984
+ stripProviderMetadata(capturedResponseMessage),
5985
+ ]);
5986
+ if (existingIdx !== -1) {
5987
+ const ok = previousAtIdx !== undefined &&
5988
+ (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5989
+ if (!ok) {
5990
+ v3_1.logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5991
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5992
+ laneCompacted = false;
5993
+ laneInjections = [];
5994
+ }
5995
+ }
5996
+ else {
5997
+ accumulatedMessages.push(...responseModelMessages);
5998
+ }
5999
+ turnNewModelMessages.push(...responseModelMessages);
6000
+ }
6001
+ catch {
6002
+ // Conversion failed — skip accumulation for this turn
5187
6003
  }
5188
- turnNewModelMessages.push(...responseModelMessages);
5189
6004
  }
5190
- catch {
5191
- // Conversion failed — skip accumulation for this turn
6005
+ else {
6006
+ responseWasSkipped = true;
5192
6007
  }
5193
6008
  }
5194
6009
  // If there's no captured response (manual pipe mode) but there are
@@ -5288,6 +6103,8 @@ function chatAgent(options) {
5288
6103
  ],
5289
6104
  },
5290
6105
  ];
6106
+ laneCompacted = true;
6107
+ laneInjections = [];
5291
6108
  // UI messages: callback or default (preserve all)
5292
6109
  if (outerCompaction.compactUIMessages) {
5293
6110
  accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
@@ -5377,6 +6194,8 @@ function chatAgent(options) {
5377
6194
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5378
6195
  accumulatedUIMessages = [...override];
5379
6196
  accumulatedMessages = await toModelMessages(override);
6197
+ laneCompacted = false;
6198
+ laneInjections = [];
5380
6199
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5381
6200
  // Update event so onTurnComplete sees compacted messages
5382
6201
  turnCompleteEvent.messages = accumulatedMessages;
@@ -5405,9 +6224,23 @@ function chatAgent(options) {
5405
6224
  capturedPartialResponse = capturedResponseMessage;
5406
6225
  turnCompleteEvent.responseMessage = capturedResponseMessage;
5407
6226
  turnCompleteEvent.uiMessages = accumulatedUIMessages;
6227
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6228
+ }
6229
+ else if (responseWasSkipped) {
6230
+ capturedResponseMessage = {
6231
+ ...capturedResponseMessage,
6232
+ parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
6233
+ };
6234
+ accumulatedUIMessages.push(capturedResponseMessage);
6235
+ turnNewUIMessages.push(capturedResponseMessage);
6236
+ capturedPartialResponse = capturedResponseMessage;
6237
+ turnCompleteEvent.responseMessage = capturedResponseMessage;
6238
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
6239
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5408
6240
  }
5409
6241
  locals_js_1.locals.set(chatResponsePartsKey, []);
5410
6242
  }
6243
+ settleRecoveredTurn(currentWirePayload);
5411
6244
  // Write turn-complete control chunk — closes the frontend stream.
5412
6245
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
5413
6246
  // Fire onTurnComplete — stream is closed, use for persistence.
@@ -5423,6 +6256,8 @@ function chatAgent(options) {
5423
6256
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5424
6257
  accumulatedUIMessages = [...turnCompleteOverride];
5425
6258
  accumulatedMessages = await toModelMessages(turnCompleteOverride);
6259
+ laneCompacted = false;
6260
+ laneInjections = [];
5426
6261
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5427
6262
  }
5428
6263
  }, {
@@ -5476,13 +6311,18 @@ function chatAgent(options) {
5476
6311
  if (!hydrateMessages) {
5477
6312
  try {
5478
6313
  await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
5479
- const snapshotInCursor = chatInputRouter().resumeFloor();
5480
- await writeChatSnapshot(sessionIdForSnapshot, {
5481
- version: 1,
5482
- savedAt: Date.now(),
6314
+ lastSnapshotOutEventId =
6315
+ turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6316
+ await saveTranscript({
6317
+ reason: "turn-complete",
5483
6318
  messages: accumulatedUIMessages,
5484
- lastOutEventId: turnCompleteResult?.lastEventId,
5485
- lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6319
+ turn,
6320
+ trigger: storageTrigger(currentWirePayload.trigger),
6321
+ clientData,
6322
+ lastOutEventId: lastSnapshotOutEventId,
6323
+ nonFinalIds: wasStopped && capturedResponseMessage?.id
6324
+ ? new Set([capturedResponseMessage.id])
6325
+ : undefined,
5486
6326
  });
5487
6327
  }, {
5488
6328
  attributes: {
@@ -5512,13 +6352,20 @@ function chatAgent(options) {
5512
6352
  // produced these from in-flight user messages on session.in
5513
6353
  // that the dead predecessor never acknowledged.
5514
6354
  if (bootInjectedQueue.length > 0) {
5515
- currentWirePayload = bootInjectedQueue.shift();
6355
+ currentWirePayload = dispatchBootInjected();
5516
6356
  return "continue";
5517
6357
  }
5518
- // chat.requestUpgrade() was called — exit the loop so the
5519
- // transport triggers a new run on the latest version.
6358
+ // chat.requestUpgrade() was called — exit the loop; the handover
6359
+ // has already triggered a new run on the latest version.
5520
6360
  // chat.endRun() — same exit, no upgrade semantics.
6361
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6362
+ await performChatClose();
6363
+ return "exit";
6364
+ }
5521
6365
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
6366
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
6367
+ await persistUpgradeHandoff();
6368
+ }
5522
6369
  return "exit";
5523
6370
  }
5524
6371
  // Wait for the next message — stay idle briefly, then suspend
@@ -5620,11 +6467,17 @@ function chatAgent(options) {
5620
6467
  let errorTurnCompleteResult;
5621
6468
  try {
5622
6469
  await withChatWriter(async (writer) => {
5623
- const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
6470
+ const errorText = turnError instanceof Error && turnError.message
6471
+ ? turnError.message
6472
+ : "An unexpected error occurred";
5624
6473
  writer.write({ type: "error", errorText });
5625
6474
  });
5626
6475
  // Signal turn complete so the client knows this turn is done
5627
6476
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6477
+ // A later action's snapshot reuses this cursor, so it has to move
6478
+ // here too or that snapshot resumes from before the failed turn.
6479
+ lastSnapshotOutEventId =
6480
+ errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
5628
6481
  }
5629
6482
  catch {
5630
6483
  // Best-effort — if stream write fails, let the run continue anyway
@@ -5664,20 +6517,56 @@ function chatAgent(options) {
5664
6517
  }
5665
6518
  }
5666
6519
  const includePartial = partialResponse != null && !responseCommitted;
6520
+ // What the stream left behind, by content. After `onTurnComplete` the
6521
+ // partial is still unfinished only if the message under its id is
6522
+ // byte-for-byte this: a clone keeps it partial, an edit finishes it.
6523
+ const partialFingerprint = includePartial && partialResponse ? (0, transcriptStorage_js_1.fingerprintMessage)(partialResponse) : undefined;
5667
6524
  let erroredUIMessagesWithPartial = !includePartial
5668
6525
  ? erroredUIMessages
5669
6526
  : partialIdx === -1
5670
6527
  ? [...erroredUIMessages, partialResponse]
5671
6528
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
5672
- let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5673
- if (includePartial) {
5674
- erroredNewUIMessages.push(partialResponse);
5675
- }
6529
+ /**
6530
+ * Seeded from the per-turn list, not just the wire message and the
6531
+ * partial, so a steering message the drain consumed is reported too.
6532
+ * An app persisting from `newUIMessages` would otherwise lose the
6533
+ * instruction whenever the turn it steered went on to fail.
6534
+ */
6535
+ const buildErroredNew = () => {
6536
+ const out = [];
6537
+ const addUnique = (m) => {
6538
+ if (m && !out.some((existing) => existing.id === m.id))
6539
+ out.push(m);
6540
+ };
6541
+ addUnique(erroredWireMessage);
6542
+ for (const m of (locals_js_1.locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6543
+ addUnique(m);
6544
+ }
6545
+ if (includePartial)
6546
+ addUnique(partialResponse);
6547
+ return out;
6548
+ };
6549
+ let erroredNewUIMessages = buildErroredNew();
5676
6550
  let erroredNewModelMessages = [];
6551
+ const reconciledSteer = reconcilePendingSteer();
6552
+ const backgroundTailThisTurn = reconcilePendingBackground();
5677
6553
  if (!responseCommitted) {
5678
6554
  try {
5679
6555
  if (erroredNewUIMessages.length > 0) {
5680
- erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6556
+ /**
6557
+ * Built in order from the recorded forms rather than by
6558
+ * converting the UI list, so a steer appears in the delta as
6559
+ * the model received it (what `prepare` produced), matching the
6560
+ * lane. The wire message and partial are converted as before.
6561
+ */
6562
+ const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6563
+ for (const m of erroredNewUIMessages) {
6564
+ const recorded = steerModelById.get(m.id);
6565
+ if (recorded)
6566
+ erroredNewModelMessages.push(...recorded);
6567
+ else
6568
+ erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6569
+ }
5681
6570
  }
5682
6571
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
5683
6572
  if (partialIdx === -1) {
@@ -5685,7 +6574,14 @@ function chatAgent(options) {
5685
6574
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
5686
6575
  }
5687
6576
  else {
5688
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6577
+ const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
6578
+ backgroundTailThisTurn);
6579
+ if (!ok) {
6580
+ v3_1.logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6581
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6582
+ laneCompacted = false;
6583
+ laneInjections = [];
6584
+ }
5689
6585
  }
5690
6586
  accumulatedUIMessages = erroredUIMessagesWithPartial;
5691
6587
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -5694,9 +6590,14 @@ function chatAgent(options) {
5694
6590
  catch {
5695
6591
  erroredNewModelMessages = [];
5696
6592
  erroredUIMessagesWithPartial = erroredUIMessages;
5697
- erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6593
+ erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5698
6594
  }
5699
6595
  }
6596
+ // An earlier hook that set the history and then threw (which is one way
6597
+ // to get here) left its abandoned edit pending. Discard it before the
6598
+ // failed turn continues, so neither the error-path `onTurnComplete`
6599
+ // below nor the next turn's history reads mistake it for a real edit.
6600
+ locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5700
6601
  if (onTurnComplete) {
5701
6602
  try {
5702
6603
  await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -5725,6 +6626,23 @@ function chatAgent(options) {
5725
6626
  error: turnError,
5726
6627
  lastEventId: errorTurnCompleteResult?.lastEventId,
5727
6628
  });
6629
+ // The hook may edit the history here too (a failure record, a
6630
+ // card the turn left open). Honour it the way the success path
6631
+ // does, so the edit reaches the accumulator and the save below.
6632
+ const errorTurnOverride = locals_js_1.locals.get(chatOverrideMessagesKey);
6633
+ if (errorTurnOverride) {
6634
+ locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
6635
+ // Convert first: a rejected conversion (a tool's `toModelOutput`
6636
+ // can throw) must leave every lane on the history it had.
6637
+ const overrideUIMessages = [...errorTurnOverride];
6638
+ const overrideModelMessages = await toModelMessages(errorTurnOverride);
6639
+ erroredUIMessagesWithPartial = overrideUIMessages;
6640
+ accumulatedUIMessages = overrideUIMessages;
6641
+ accumulatedMessages = overrideModelMessages;
6642
+ laneCompacted = false;
6643
+ laneInjections = [];
6644
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6645
+ }
5728
6646
  }, {
5729
6647
  attributes: {
5730
6648
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
@@ -5738,6 +6656,7 @@ function chatAgent(options) {
5738
6656
  catch {
5739
6657
  // A throwing onTurnComplete on the error path must not crash
5740
6658
  // the run — keep the conversation alive for the next message.
6659
+ locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5741
6660
  }
5742
6661
  }
5743
6662
  // Persist a snapshot so the failed turn's user message isn't
@@ -5748,13 +6667,17 @@ function chatAgent(options) {
5748
6667
  // neither the snapshot nor the replayable `.in` tail.
5749
6668
  if (!hydrateMessages) {
5750
6669
  try {
5751
- const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5752
- await writeChatSnapshot(sessionIdForSnapshot, {
5753
- version: 1,
5754
- savedAt: Date.now(),
6670
+ await saveTranscript({
6671
+ reason: "turn-error",
5755
6672
  messages: erroredUIMessagesWithPartial,
5756
- lastOutEventId: errorTurnCompleteResult?.lastEventId,
5757
- lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
6673
+ turn,
6674
+ trigger: storageTrigger(currentWirePayload.trigger),
6675
+ clientData: turnClientData,
6676
+ lastOutEventId: lastSnapshotOutEventId,
6677
+ // The partial is non-final only while the message under its id is
6678
+ // still what the stream left behind. A hook that replaced it (a
6679
+ // closed card, a finished body) produced a final message.
6680
+ nonFinalIds: partialStillUnfinished(partialResponse, partialFingerprint, erroredUIMessagesWithPartial),
5758
6681
  });
5759
6682
  }
5760
6683
  catch (error) {
@@ -5764,15 +6687,22 @@ function chatAgent(options) {
5764
6687
  });
5765
6688
  }
5766
6689
  }
6690
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6691
+ await performChatClose();
6692
+ return;
6693
+ }
5767
6694
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
5768
6695
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
6696
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
6697
+ await persistUpgradeHandoff();
6698
+ }
5769
6699
  return;
5770
6700
  }
5771
6701
  // Drain remaining recovered turns before idling — a thrown
5772
6702
  // recovered turn shouldn't strand the rest of the boot queue
5773
6703
  // until an unrelated live message arrives.
5774
6704
  if (bootInjectedQueue.length > 0) {
5775
- currentWirePayload = bootInjectedQueue.shift();
6705
+ currentWirePayload = dispatchBootInjected();
5776
6706
  continue;
5777
6707
  }
5778
6708
  // Wait for the next message — same as after a successful turn
@@ -5788,6 +6718,12 @@ function chatAgent(options) {
5788
6718
  return; // Timed out — end run gracefully
5789
6719
  }
5790
6720
  currentWirePayload = next.output;
6721
+ // Same close check the success path makes. Without it a close
6722
+ // record that lands after a failed turn is consumed as if it were
6723
+ // a turn payload, and the loop runs on against a closed session.
6724
+ if (currentWirePayload.trigger === "close") {
6725
+ return;
6726
+ }
5791
6727
  // Continue to next iteration of the for loop
5792
6728
  }
5793
6729
  finally {
@@ -5796,6 +6732,11 @@ function chatAgent(options) {
5796
6732
  }
5797
6733
  }
5798
6734
  finally {
6735
+ // Safety net for a close requested on a path that exits without
6736
+ // reaching one of the loop's close checks (a turn timeout, an OOM
6737
+ // re-throw). `performChatClose` is idempotent, so the ordinary path
6738
+ // having already run it costs nothing here.
6739
+ await performChatClose();
5799
6740
  // `stopSub` is registered post-preload so the close-during-preload
5800
6741
  // early-return path may exit before it ever attached. Guard the
5801
6742
  // cleanup so a missing subscription doesn't throw.
@@ -6133,15 +7074,22 @@ function isStopped() {
6133
7074
  // Version upgrade
6134
7075
  // ---------------------------------------------------------------------------
6135
7076
  /**
6136
- * Request that the current run exits so the next message starts on the latest
6137
- * deployed version (via the standard continuation mechanism).
7077
+ * Hand the conversation over to another deployment.
7078
+ *
7079
+ * The handover happens immediately and server-side: a successor run is created
7080
+ * and picks the conversation up from `session.in`. The transport keeps reading
7081
+ * the same session output, so no client action is needed and nothing waits for
7082
+ * the next message.
7083
+ *
7084
+ * Without a target the session's pin is cleared, so the successor lands on the
7085
+ * latest deployed version; with `externalDeploymentId` the session is re-pinned
7086
+ * to that deployment.
6138
7087
  *
6139
7088
  * When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped
6140
- * entirely the run exits immediately and the transport re-triggers the
6141
- * same message on the new version.
7089
+ * entirely and the successor answers the message that opened the turn.
6142
7090
  *
6143
7091
  * When called from `run()` or `chat.defer()`, the current turn completes
6144
- * normally and the run exits afterward instead of waiting for the next message.
7092
+ * normally and the handover happens afterward.
6145
7093
  *
6146
7094
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6147
7095
  * or inside `chat.defer()`.
@@ -6161,8 +7109,37 @@ function isStopped() {
6161
7109
  * });
6162
7110
  * ```
6163
7111
  */
6164
- function requestUpgrade() {
7112
+ function requestUpgrade(options) {
6165
7113
  locals_js_1.locals.set(chatUpgradeRequestedKey, true);
7114
+ // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
7115
+ const target = options?.externalDeploymentId?.trim();
7116
+ if (target)
7117
+ locals_js_1.locals.set(chatUpgradeExternalDeploymentIdKey, target);
7118
+ }
7119
+ /** @internal Requests a handoff when the session's pin no longer names this deployment. */
7120
+ async function followSessionPin(chatId, policy) {
7121
+ if (!chatId) {
7122
+ return;
7123
+ }
7124
+ const deployedExternalId = locals_js_1.locals.get(chatAgentRunContextKey)?.deployment?.externalId;
7125
+ if (policy !== "hold" && !deployedExternalId) {
7126
+ v3_1.logger.debug("chat.versionSkew: cannot follow the session pin", {
7127
+ chatId,
7128
+ reason: "the run context carries no deployment.externalId",
7129
+ });
7130
+ }
7131
+ const target = await (0, chatVersionSkew_js_1.resolvePinToFollow)({
7132
+ policy,
7133
+ deployedExternalId,
7134
+ upgradeAlreadyRequested: locals_js_1.locals.get(chatUpgradeRequestedKey) === true,
7135
+ readPin: async () => (await sessions_js_1.sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
7136
+ .triggerConfig,
7137
+ });
7138
+ if (!target) {
7139
+ return;
7140
+ }
7141
+ v3_1.logger.info("chat.versionSkew: following the session pin", { chatId, target });
7142
+ requestUpgrade({ externalDeploymentId: target });
6166
7143
  }
6167
7144
  /**
6168
7145
  * Hand off the current custom agent Session to a fresh run.
@@ -6201,20 +7178,31 @@ async function endAndContinue() {
6201
7178
  if ((locals_js_1.locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6202
7179
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6203
7180
  }
6204
- await performEndAndContinue();
7181
+ await performEndAndContinue({ reason: "continuation" });
6205
7182
  }
6206
7183
  /** @internal Shared server handoff used by managed and custom agent loops. */
6207
- async function performEndAndContinue() {
7184
+ async function performEndAndContinue(options) {
6208
7185
  const chatId = locals_js_1.locals.get(chatExternalIdKey);
6209
7186
  const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
6210
7187
  if (!chatId || !callingRunId) {
6211
7188
  throw new Error("Cannot end and continue without an active chat agent run");
6212
7189
  }
7190
+ const externalDeploymentId = options.externalDeploymentId;
6213
7191
  const apiClient = v3_1.apiClientManager.clientOrThrow();
6214
- await apiClient.endAndContinueSession(chatId, {
7192
+ const result = await apiClient.endAndContinueSession(chatId, {
6215
7193
  callingRunId,
6216
- reason: "upgrade",
7194
+ reason: options.reason,
7195
+ ...(externalDeploymentId ? { externalDeploymentId } : {}),
6217
7196
  });
7197
+ if (result?.pendingVersion !== true) {
7198
+ return;
7199
+ }
7200
+ // The successor parked. Say so on `.out` while this run still can — the transport's
7201
+ // subscription survives the swap, so the client learns without waiting for its next send.
7202
+ const [error] = await (0, v3_1.tryCatch)(getChatSession().out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
7203
+ if (error) {
7204
+ v3_1.logger.warn("could not signal a parked handoff", { chatId, error });
7205
+ }
6218
7206
  }
6219
7207
  /**
6220
7208
  * Exit the run after the current turn completes, without waiting for the
@@ -6245,6 +7233,124 @@ async function performEndAndContinue() {
6245
7233
  function endRun() {
6246
7234
  locals_js_1.locals.set(chatEndRunRequestedKey, true);
6247
7235
  }
7236
+ /**
7237
+ * End the whole conversation, permanently. The session row is closed, further
7238
+ * appends are refused, and the run exits without scheduling a continuation.
7239
+ *
7240
+ * This is the session-level stop. {@link endRun} ends the current run and lets
7241
+ * the next message start a fresh one; `chat.close()` ends the session itself,
7242
+ * so there is no next message. Use it for a budget cap, a completed goal,
7243
+ * abuse detection, or a user signing out.
7244
+ *
7245
+ * In a `chat.agent`, call it from `run()`, `prepareStep`, or
7246
+ * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
7247
+ * `streamText` the same way the stop signal does, so the partial response is
7248
+ * still captured and streamed. The turn then completes normally, a terminal
7249
+ * `session-closed` record carrying `reason` is written to the response stream,
7250
+ * and the loop exits.
7251
+ *
7252
+ * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
7253
+ * fields but still runs while the stream is open, so the closed state rides
7254
+ * out on the turn's final record. `onTurnComplete` runs after that record, so
7255
+ * a close decided there does not reach a reader that has already finished the
7256
+ * turn, and the user only finds out when their next message is refused.
7257
+ *
7258
+ * In a `chat.customAgent`, call it anywhere in your own loop. The close is
7259
+ * performed when `run()` returns, so it lands whether you break out of a
7260
+ * `chat.createSession` loop, return early, or hand-roll the loop entirely.
7261
+ *
7262
+ * Closing is one-way: a closed session cannot be reopened. Its transcript
7263
+ * stays readable.
7264
+ *
7265
+ * @example
7266
+ * ```ts
7267
+ * chat.agent({
7268
+ * id: "budgeted-agent",
7269
+ * onBeforeTurnComplete: async ({ usage }) => {
7270
+ * if (await overBudget(usage)) {
7271
+ * chat.close({ reason: "Monthly budget reached" });
7272
+ * }
7273
+ * },
7274
+ * });
7275
+ * ```
7276
+ */
7277
+ function close(options) {
7278
+ if (!locals_js_1.locals.get(chatExternalIdKey)) {
7279
+ throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
7280
+ }
7281
+ // Bound the reason once, here. It goes out on S2 record headers as well as
7282
+ // the close API, and an oversized value would fail the turn-complete write
7283
+ // that carries the turn boundary, costing the client far more than the
7284
+ // reason text.
7285
+ // Trailing high surrogate: the cut landed between the two halves of an
7286
+ // astral character, and encoding the orphan to UTF-8 for a record header
7287
+ // yields a replacement character. Drop it rather than ship mojibake.
7288
+ const reason = options?.reason
7289
+ ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
7290
+ .replace(/[\uD800-\uDBFF]$/, "");
7291
+ locals_js_1.locals.set(chatCloseRequestedKey, reason ? { reason } : {});
7292
+ // Mid-step call: unblock the in-flight streamText exactly like the stop
7293
+ // signal, so the turn can reach its turn boundary instead of running the
7294
+ // model out to completion after the decision to close has been made.
7295
+ locals_js_1.locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
7296
+ }
7297
+ /**
7298
+ * @internal Terminal close sequence, run once at whichever exit site observes
7299
+ * the close request. Writes the standalone `session-closed` record, then closes
7300
+ * the session row.
7301
+ *
7302
+ * The record lands after the turn's `turn-complete`, so a client reading that
7303
+ * turn's stream has already terminated on it and will not see this one. It is
7304
+ * there for a reconnect and for replay. What a live client reads is the
7305
+ * `session-closed` header stamped onto `turn-complete` itself by
7306
+ * `writeTurnCompleteChunk`, which fires whenever the close was decided before
7307
+ * the turn ended. A close decided from `onTurnComplete` is past that point, so
7308
+ * the client learns from the 409 on its next send.
7309
+ */
7310
+ async function performChatClose() {
7311
+ const request = locals_js_1.locals.get(chatCloseRequestedKey);
7312
+ if (!request || locals_js_1.locals.get(chatClosePerformedKey))
7313
+ return;
7314
+ const reason = request.reason;
7315
+ // Two flags, not one. The record is a client-visible event and must not be
7316
+ // written twice, but the row close is the part that actually ends the
7317
+ // conversation: flagging it as done before it succeeds would let a transient
7318
+ // failure leave the session open with no later call willing to retry.
7319
+ if (!locals_js_1.locals.get(chatCloseRecordWrittenKey)) {
7320
+ locals_js_1.locals.set(chatCloseRecordWrittenKey, true);
7321
+ try {
7322
+ const session = getChatSession();
7323
+ await session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[v3_1.SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
7324
+ }
7325
+ catch (error) {
7326
+ v3_1.logger.warn("chat.close: failed to write the session-closed record", {
7327
+ error: error instanceof Error ? error.message : String(error),
7328
+ });
7329
+ }
7330
+ }
7331
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
7332
+ if (!chatId)
7333
+ return;
7334
+ try {
7335
+ await sessions_js_1.sessions.close(chatId, {
7336
+ ...(reason ? { reason } : {}),
7337
+ ...(locals_js_1.locals.get(chatAgentRunContextKey)?.run.id
7338
+ ? { callingRunId: locals_js_1.locals.get(chatAgentRunContextKey).run.id }
7339
+ : {}),
7340
+ });
7341
+ locals_js_1.locals.set(chatClosePerformedKey, true);
7342
+ }
7343
+ catch (error) {
7344
+ // Deliberately NOT flagged as performed: the close API is idempotent, so a
7345
+ // later exit site on this run gets to retry it. Losing every retry to a
7346
+ // transient failure would leave the row open and the conversation alive.
7347
+ // Non-fatal either way — the run still exits.
7348
+ v3_1.logger.error("chat.close: failed to close the session", {
7349
+ chatId,
7350
+ error: error instanceof Error ? error.message : String(error),
7351
+ });
7352
+ }
7353
+ }
6248
7354
  // ---------------------------------------------------------------------------
6249
7355
  // Per-turn deferred work
6250
7356
  // ---------------------------------------------------------------------------
@@ -6306,9 +7412,18 @@ function chatDefer(promiseOrFn) {
6306
7412
  * ```
6307
7413
  */
6308
7414
  function injectBackgroundContext(messages) {
6309
- const queue = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
6310
- queue.push(...messages);
6311
- locals_js_1.locals.set(chatBackgroundQueueKey, queue);
7415
+ const systemBlocks = messages.filter((message) => message.role === "system");
7416
+ const conversational = messages.filter((message) => message.role !== "system");
7417
+ if (systemBlocks.length > 0) {
7418
+ const instructions = locals_js_1.locals.get(chatInjectedInstructionsKey) ?? [];
7419
+ instructions.push(...systemBlocks);
7420
+ locals_js_1.locals.set(chatInjectedInstructionsKey, instructions);
7421
+ }
7422
+ if (conversational.length > 0) {
7423
+ const queue = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
7424
+ queue.push(...conversational);
7425
+ locals_js_1.locals.set(chatBackgroundQueueKey, queue);
7426
+ }
6312
7427
  }
6313
7428
  // ---------------------------------------------------------------------------
6314
7429
  // Aborted message cleanup
@@ -6721,10 +7836,12 @@ class ChatMessageAccumulator {
6721
7836
  // a duplicate, mirroring the chat.agent accumulator.
6722
7837
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
6723
7838
  if (existingIdx !== -1) {
7839
+ const previous = this.uiMessages[existingIdx];
6724
7840
  this.uiMessages[existingIdx] = response;
6725
7841
  try {
6726
- // Reconvert all model messages since we replaced rather than appended.
6727
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7842
+ if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7843
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7844
+ }
6728
7845
  }
6729
7846
  catch {
6730
7847
  // Conversion failed — leave the existing model messages in place
@@ -6760,6 +7877,28 @@ class ChatMessageAccumulator {
6760
7877
  const modelMsgs = await toModelMessages([message]);
6761
7878
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
6762
7879
  }
7880
+ /**
7881
+ * Record the messages a steering drain consumed.
7882
+ *
7883
+ * The drain only puts them in this step's prompt, so without this they
7884
+ * shape one answer and then exist in neither lane: not in `uiMessages`,
7885
+ * which is what an app persists from, and not in `modelMessages`, which is
7886
+ * what every later turn sends.
7887
+ *
7888
+ * Both lanes are appended to. The model lane is never reconverted from the
7889
+ * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7890
+ * the UI lane whole: a reconversion would restore everything the summary
7891
+ * replaced.
7892
+ */
7893
+ async absorbSteering(claimed, injected) {
7894
+ const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7895
+ if (fresh.length === 0)
7896
+ return;
7897
+ this.uiMessages.push(...fresh);
7898
+ // Record what the model received. Only when the whole batch is new is
7899
+ // `injected` known to describe exactly these messages.
7900
+ this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7901
+ }
6763
7902
  /**
6764
7903
  * Get and clear unconsumed steering messages.
6765
7904
  */
@@ -6792,7 +7931,8 @@ class ChatMessageAccumulator {
6792
7931
  }
6793
7932
  // 2. Pending message injection
6794
7933
  if (pm && queue.length > 0) {
6795
- const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7934
+ const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7935
+ await this.absorbSteering(claimed, injected);
6796
7936
  if (injected.length > 0) {
6797
7937
  resultMessages = [...(resultMessages ?? messages), ...injected];
6798
7938
  }
@@ -6981,7 +8121,7 @@ function trackActiveChatSessionIterator(iterator) {
6981
8121
  * ```
6982
8122
  */
6983
8123
  function createChatSession(payload, options) {
6984
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
8124
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6985
8125
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
6986
8126
  return {
6987
8127
  [Symbol.asyncIterator]() {
@@ -7070,8 +8210,16 @@ function createChatSession(payload, options) {
7070
8210
  * without suspending.
7071
8211
  */
7072
8212
  if (turn > 0) {
8213
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
8214
+ await performChatClose();
8215
+ stop.cleanup();
8216
+ return { done: true, value: undefined };
8217
+ }
7073
8218
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7074
8219
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
8220
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
8221
+ await persistUpgradeHandoff();
8222
+ }
7075
8223
  stop.cleanup();
7076
8224
  return { done: true, value: undefined };
7077
8225
  }
@@ -7174,6 +8322,7 @@ function createChatSession(payload, options) {
7174
8322
  }
7175
8323
  accumulator.applyHandover(pendingHandoverSignal);
7176
8324
  }
8325
+ await followSessionPin(currentPayload.chatId, sessionVersionSkew);
7177
8326
  // chat.requestUpgrade() called before this turn — signal transport and exit
7178
8327
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
7179
8328
  await writeUpgradeRequiredChunk();
@@ -7390,7 +8539,8 @@ function createChatSession(payload, options) {
7390
8539
  }
7391
8540
  }
7392
8541
  if (sessionPendingMessages) {
7393
- const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8542
+ const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8543
+ await accumulator.absorbSteering(claimed, injected);
7394
8544
  if (injected.length > 0) {
7395
8545
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
7396
8546
  }
@@ -7404,6 +8554,11 @@ function createChatSession(payload, options) {
7404
8554
  async return() {
7405
8555
  activeMsgSub?.off();
7406
8556
  activeMsgSub = undefined;
8557
+ // Reached when the consumer leaves the `for await` early (`break`,
8558
+ // `return`, a throw). A `chat.close()` from the loop body would
8559
+ // otherwise be dropped: the exit that performs it lives in `next()`,
8560
+ // and `next()` is never called again.
8561
+ await performChatClose();
7407
8562
  // `stop` only exists once next() has booted the iterator.
7408
8563
  stop?.cleanup();
7409
8564
  return { done: true, value: undefined };
@@ -7660,6 +8815,11 @@ function createChatStartSessionAction(taskId, options) {
7660
8815
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
7661
8816
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
7662
8817
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8818
+ // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8819
+ // action default, which neither truthiness nor `??` would allow.
8820
+ const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8821
+ ? params.triggerConfig.externalDeploymentId
8822
+ : options?.triggerConfig?.externalDeploymentId;
7663
8823
  const triggerConfig = {
7664
8824
  basePayload: {
7665
8825
  messages: [],
@@ -7686,6 +8846,10 @@ function createChatStartSessionAction(taskId, options) {
7686
8846
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
7687
8847
  }
7688
8848
  : {}),
8849
+ ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8850
+ ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8851
+ : {}),
8852
+ ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
7689
8853
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
7690
8854
  };
7691
8855
  const startBody = {
@@ -7730,6 +8894,7 @@ function createChatStartSessionAction(taskId, options) {
7730
8894
  publicAccessToken,
7731
8895
  runId: created.runId,
7732
8896
  sessionId: created.id,
8897
+ ...(created.pendingVersion ? { pendingVersion: true } : {}),
7733
8898
  };
7734
8899
  };
7735
8900
  }
@@ -7763,7 +8928,8 @@ async function callSessionsCreateWithOverride(args) {
7763
8928
  const init = {
7764
8929
  method: "POST",
7765
8930
  headers: overrideRequestHeaders(accessToken),
7766
- body: JSON.stringify(args.body),
8931
+ // This path bypasses `sessions.start`, so it resolves the pin itself.
8932
+ body: JSON.stringify((0, externalDeploymentId_js_1.withResolvedExternalDeploymentId)(args.body)),
7767
8933
  };
7768
8934
  const response = args.fetchOverride
7769
8935
  ? await args.fetchOverride(url, init, ctx)
@@ -7828,6 +8994,41 @@ async function mintPublicTokenWithOverride(args) {
7828
8994
  expirationTime: args.expirationTime,
7829
8995
  });
7830
8996
  }
8997
+ /**
8998
+ * Creates a server-side helper that reads a conversation from a transcript
8999
+ * storage, for rendering history before the chat connects. Works the same
9000
+ * for every storage, the platform default included, so the browser never
9001
+ * reads a store directly and the secret key stays on the server.
9002
+ *
9003
+ * Wrap it in a Next.js server action (or any server-side handler), scope it
9004
+ * to the authenticated user through `clientData`, and pass the result to
9005
+ * `useLoadTranscript` in the browser.
9006
+ *
9007
+ * @example
9008
+ * ```ts
9009
+ * // actions.ts
9010
+ * "use server";
9011
+ * import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
9012
+ *
9013
+ * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
9014
+ * ```
9015
+ */
9016
+ function createChatLoadTranscriptAction(storage, options) {
9017
+ return async (params) => {
9018
+ if (!params.chatId) {
9019
+ throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
9020
+ }
9021
+ if (options?.apiClient) {
9022
+ const { apiClient, ...rest } = options;
9023
+ return v3_1.apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
9024
+ }
9025
+ const limit = params.limit ?? options?.limit;
9026
+ return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
9027
+ ...(limit !== undefined ? { limit } : {}),
9028
+ ...(params.before !== undefined ? { before: params.before } : {}),
9029
+ });
9030
+ };
9031
+ }
7831
9032
  exports.chat = {
7832
9033
  /** Create a chat agent. See {@link chatAgent}. */
7833
9034
  agent: chatAgent,
@@ -7839,8 +9040,12 @@ exports.chat = {
7839
9040
  withClientData,
7840
9041
  /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
7841
9042
  createStartSessionAction: createChatStartSessionAction,
9043
+ /** Returns a server-side helper that reads a conversation from a transcript storage. */
9044
+ createLoadTranscriptAction: createChatLoadTranscriptAction,
7842
9045
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
7843
9046
  pipe: pipeChat,
9047
+ /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
9048
+ turn: chatTurn,
7844
9049
  /** Create a per-run typed local. See {@link chatLocal}. */
7845
9050
  local: chatLocal,
7846
9051
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -7861,10 +9066,13 @@ exports.chat = {
7861
9066
  endAndContinue,
7862
9067
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7863
9068
  endRun,
9069
+ /** End the conversation permanently: close the session and exit the run. See {@link close}. */
9070
+ close,
7864
9071
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
7865
9072
  cleanupAbortedParts,
7866
9073
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
7867
9074
  defer: chatDefer,
9075
+ deferBeforeOutput: chatDeferBeforeOutput,
7868
9076
  /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */
7869
9077
  inject: injectBackgroundContext,
7870
9078
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */
@@ -8008,6 +9216,16 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8008
9216
  if (consumedCursor !== undefined) {
8009
9217
  extraHeaders.push([v3_1.SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8010
9218
  }
9219
+ // A close decided before this turn ended rides out on turn-complete. Readers
9220
+ // terminate their stream on turn-complete, so a standalone record written
9221
+ // after it only reaches a reconnect — this header is what a live client sees.
9222
+ const pendingClose = locals_js_1.locals.get(chatCloseRequestedKey);
9223
+ if (pendingClose) {
9224
+ extraHeaders.push([v3_1.SESSION_CLOSED_HEADER, "true"]);
9225
+ if (pendingClose.reason) {
9226
+ extraHeaders.push([v3_1.SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
9227
+ }
9228
+ }
8011
9229
  const result = await session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8012
9230
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8013
9231
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8065,12 +9283,47 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8065
9283
  *
8066
9284
  * @internal
8067
9285
  */
9286
+ /**
9287
+ * Persists an upgrade requested after the turn has already run.
9288
+ *
9289
+ * The pre-turn sites reach {@link performEndAndContinue} through
9290
+ * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
9291
+ * session's stored `externalDeploymentId`. The post-turn exits had no such path,
9292
+ * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
9293
+ * and every continuation re-pinned to the deployment the agent asked to leave.
9294
+ *
9295
+ * No `upgrade-required` chunk is written here: the turn already produced its
9296
+ * answer, so there is nothing for a client to be told about.
9297
+ */
9298
+ async function persistUpgradeHandoff() {
9299
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
9300
+ const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
9301
+ if (!chatId || !callingRunId) {
9302
+ return;
9303
+ }
9304
+ try {
9305
+ await performEndAndContinue({
9306
+ reason: "upgrade",
9307
+ externalDeploymentId: locals_js_1.locals.get(chatUpgradeExternalDeploymentIdKey),
9308
+ });
9309
+ }
9310
+ catch (error) {
9311
+ v3_1.logger.warn("upgrade handoff failed; session keeps its current version pin", {
9312
+ chatId,
9313
+ callingRunId,
9314
+ error,
9315
+ });
9316
+ }
9317
+ }
8068
9318
  async function writeUpgradeRequiredChunk() {
8069
9319
  const chatId = locals_js_1.locals.get(chatExternalIdKey);
8070
9320
  const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
8071
9321
  if (chatId && callingRunId) {
8072
9322
  try {
8073
- await performEndAndContinue();
9323
+ await performEndAndContinue({
9324
+ reason: "upgrade",
9325
+ externalDeploymentId: locals_js_1.locals.get(chatUpgradeExternalDeploymentIdKey),
9326
+ });
8074
9327
  }
8075
9328
  catch (error) {
8076
9329
  // Non-fatal: the next `.in/append` re-triggers via the probe.