@trigger.dev/sdk 0.0.0-prerelease-streamfix-20260909094302 → 0.0.0-prerelease-20260909121655

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 (142) 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 +275 -25
  7. package/dist/commonjs/v3/ai.js +1336 -340
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  10. package/dist/commonjs/v3/chat-client.js +227 -111
  11. package/dist/commonjs/v3/chat-client.js.map +1 -1
  12. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  13. package/dist/commonjs/v3/chat-react.js +130 -1
  14. package/dist/commonjs/v3/chat-react.js.map +1 -1
  15. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  16. package/dist/commonjs/v3/chat-server.js +52 -7
  17. package/dist/commonjs/v3/chat-server.js.map +1 -1
  18. package/dist/commonjs/v3/chat.d.ts +92 -10
  19. package/dist/commonjs/v3/chat.js +328 -73
  20. package/dist/commonjs/v3/chat.js.map +1 -1
  21. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  22. package/dist/commonjs/v3/chatSnapshotIo.js +174 -0
  23. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  24. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  25. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  26. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  27. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  28. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  29. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  30. package/dist/commonjs/v3/sessions.d.ts +15 -2
  31. package/dist/commonjs/v3/sessions.js +12 -1
  32. package/dist/commonjs/v3/sessions.js.map +1 -1
  33. package/dist/commonjs/v3/shared.js +30 -36
  34. package/dist/commonjs/v3/shared.js.map +1 -1
  35. package/dist/commonjs/v3/test/index.d.ts +1 -0
  36. package/dist/commonjs/v3/test/index.js +3 -1
  37. package/dist/commonjs/v3/test/index.js.map +1 -1
  38. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  39. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  40. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  41. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  42. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  43. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  44. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  45. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  46. package/dist/commonjs/v3/transcriptStorage.d.ts +224 -0
  47. package/dist/commonjs/v3/transcriptStorage.js +291 -0
  48. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  49. package/dist/commonjs/version.js +1 -1
  50. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  51. package/dist/esm/imports/ai-runtime.js +2 -2
  52. package/dist/esm/imports/ai-runtime.js.map +1 -1
  53. package/dist/esm/v3/ai-shared.d.ts +15 -0
  54. package/dist/esm/v3/ai-shared.js +33 -0
  55. package/dist/esm/v3/ai-shared.js.map +1 -1
  56. package/dist/esm/v3/ai.d.ts +275 -25
  57. package/dist/esm/v3/ai.js +1328 -337
  58. package/dist/esm/v3/ai.js.map +1 -1
  59. package/dist/esm/v3/chat-client.d.ts +11 -2
  60. package/dist/esm/v3/chat-client.js +228 -112
  61. package/dist/esm/v3/chat-client.js.map +1 -1
  62. package/dist/esm/v3/chat-react.d.ts +88 -0
  63. package/dist/esm/v3/chat-react.js +127 -1
  64. package/dist/esm/v3/chat-react.js.map +1 -1
  65. package/dist/esm/v3/chat-server.d.ts +42 -6
  66. package/dist/esm/v3/chat-server.js +53 -8
  67. package/dist/esm/v3/chat-server.js.map +1 -1
  68. package/dist/esm/v3/chat.d.ts +92 -10
  69. package/dist/esm/v3/chat.js +329 -74
  70. package/dist/esm/v3/chat.js.map +1 -1
  71. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  72. package/dist/esm/v3/chatSnapshotIo.js +166 -0
  73. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  74. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  75. package/dist/esm/v3/chatVersionSkew.js +27 -0
  76. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  77. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  78. package/dist/esm/v3/externalDeploymentId.js +38 -0
  79. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  80. package/dist/esm/v3/sessions.d.ts +15 -2
  81. package/dist/esm/v3/sessions.js +11 -1
  82. package/dist/esm/v3/sessions.js.map +1 -1
  83. package/dist/esm/v3/shared.js +17 -23
  84. package/dist/esm/v3/shared.js.map +1 -1
  85. package/dist/esm/v3/test/index.d.ts +1 -0
  86. package/dist/esm/v3/test/index.js +1 -0
  87. package/dist/esm/v3/test/index.js.map +1 -1
  88. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  89. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  90. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  91. package/dist/esm/v3/test/test-session-handle.js +6 -0
  92. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  93. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  94. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  95. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  96. package/dist/esm/v3/transcriptStorage.d.ts +224 -0
  97. package/dist/esm/v3/transcriptStorage.js +280 -0
  98. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  99. package/dist/esm/version.js +1 -1
  100. package/docs/ai-chat/actions.mdx +55 -23
  101. package/docs/ai-chat/anatomy.mdx +3 -3
  102. package/docs/ai-chat/backend.mdx +125 -48
  103. package/docs/ai-chat/background-injection.mdx +69 -19
  104. package/docs/ai-chat/client-protocol.mdx +5 -4
  105. package/docs/ai-chat/compaction.mdx +13 -7
  106. package/docs/ai-chat/custom-agents.mdx +23 -0
  107. package/docs/ai-chat/fast-starts.mdx +27 -20
  108. package/docs/ai-chat/frontend.mdx +19 -14
  109. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  110. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  111. package/docs/ai-chat/patterns/database-persistence.mdx +6 -2
  112. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  113. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  114. package/docs/ai-chat/patterns/skills.mdx +7 -10
  115. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  116. package/docs/ai-chat/pending-messages.mdx +3 -3
  117. package/docs/ai-chat/prompt-caching.mdx +23 -25
  118. package/docs/ai-chat/quick-start.mdx +11 -11
  119. package/docs/ai-chat/reference.mdx +67 -9
  120. package/docs/ai-chat/sessions.mdx +6 -1
  121. package/docs/ai-chat/testing.mdx +2 -1
  122. package/docs/ai-chat/tools.mdx +18 -13
  123. package/docs/ai-chat/transcript-storage.mdx +233 -0
  124. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  125. package/docs/apikeys.mdx +27 -45
  126. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  127. package/docs/deploy-environment-variables.mdx +23 -2
  128. package/docs/deployment/overview.mdx +4 -8
  129. package/docs/deployment/preview-branches.mdx +4 -4
  130. package/docs/deployment/version-skew-protection.mdx +62 -0
  131. package/docs/manual-setup.mdx +7 -7
  132. package/docs/mcp-tools.mdx +9 -0
  133. package/docs/quick-start.mdx +3 -3
  134. package/docs/realtime/auth.mdx +1 -1
  135. package/docs/reports.mdx +157 -0
  136. package/docs/self-hosting/security.mdx +5 -0
  137. package/docs/tasks/scheduled.mdx +24 -0
  138. package/docs/triggering.mdx +1 -1
  139. package/docs/troubleshooting-zod.mdx +158 -0
  140. package/package.json +4 -4
  141. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  142. package/skills/trigger-chat-agent-advanced/SKILL.md +31 -12
@@ -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
@@ -1045,7 +888,7 @@ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1045
888
  }
1046
889
  }
1047
890
  async function reportChatCustomAgentClientDataError(payload, error, options) {
1048
- const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
891
+ const errorText = error instanceof Error && error.message ? error.message : "An unexpected error occurred";
1049
892
  v3_1.logger.warn("chat.customAgent: clientData validation failed", {
1050
893
  chatId: payload.chatId,
1051
894
  trigger: payload.trigger,
@@ -1772,6 +1615,13 @@ async function installChatInputRouter(chatId, options) {
1772
1615
  checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1773
1616
  }
1774
1617
  }
1618
+ // A boot that replayed `.in` itself has already answered everything up to
1619
+ // `recoveredThrough`, so the floor has to cover it before the tail opens.
1620
+ if (options?.recoveredThrough !== undefined) {
1621
+ const recovered = options.recoveredThrough;
1622
+ checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
1623
+ checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? checkpoint.resumeFrom, checkpoint.resumeFrom);
1624
+ }
1775
1625
  const router = entry.router;
1776
1626
  router.restore(checkpoint);
1777
1627
  const floor = router.resumeFrom();
@@ -1780,6 +1630,10 @@ async function installChatInputRouter(chatId, options) {
1780
1630
  v3_1.sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1781
1631
  }
1782
1632
  v3_1.sessionStreams.onRecord(chatId, "in", (record) => {
1633
+ // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1634
+ // re-deliver below it and a replayable route would re-queue it.
1635
+ if (floor !== undefined && record.seqNum <= floor)
1636
+ return true;
1783
1637
  router.ingest(record);
1784
1638
  return true;
1785
1639
  });
@@ -1951,6 +1805,35 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1951
1805
  * @internal
1952
1806
  */
1953
1807
  const chatBackgroundQueueKey = locals_js_1.locals.create("chat.backgroundQueue");
1808
+ /**
1809
+ * Background injections a step-boundary drain handed to the model this turn,
1810
+ * with the transcript message they followed. Reconciled into the model lane
1811
+ * and the persisted injections once the turn's response is in.
1812
+ */
1813
+ const chatPendingBackgroundKey = locals_js_1.locals.create("chat.pendingBackground");
1814
+ /**
1815
+ * System-role context injected mid-conversation, held for the instructions lane.
1816
+ *
1817
+ * Kept apart from the message queue because ai@7 rejects a system message inside
1818
+ * `messages` for every provider — `standardizePrompt` throws upstream of any
1819
+ * provider call, and its own advice is to use the instructions option. Instructions
1820
+ * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1821
+ * home: appended as another system block rather than smuggled into the transcript.
1822
+ *
1823
+ * This is also the only way to inject *trusted* context. A message injected as
1824
+ * `user` is untrusted by construction, and a well-aligned model treats it that
1825
+ * way — it will say so, and re-derive the answer from tools instead.
1826
+ */
1827
+ const chatInjectedInstructionsKey = locals_js_1.locals.create("chat.injectedInstructions");
1828
+ /**
1829
+ * What a turn already consumed from the instructions lane, so a second
1830
+ * `toStreamTextOptions()` call in the same turn sees the same blocks.
1831
+ *
1832
+ * Consumed blocks are moved here rather than left in the pending lane: leaving
1833
+ * them there means an injection made during the consumed turn sits behind them,
1834
+ * and clearing the lane on the next turn destroys both.
1835
+ */
1836
+ const chatInstructionsConsumedKey = locals_js_1.locals.create("chat.injectedInstructionsConsumed");
1954
1837
  /**
1955
1838
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1956
1839
  * same worker don't share state.
@@ -2461,18 +2344,63 @@ const chatToolsOptionKey = locals_js_1.locals.create("chat.toolsOption");
2461
2344
  const chatResolvedToolsKey = locals_js_1.locals.create("chat.resolvedTools");
2462
2345
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2463
2346
  const chatUpgradeRequestedKey = locals_js_1.locals.create("chat.upgradeRequested");
2347
+ /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2348
+ const chatUpgradeExternalDeploymentIdKey = locals_js_1.locals.create("chat.upgradeExternalDeploymentId");
2464
2349
  /**
2465
2350
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2466
2351
  * turn completes, without any upgrade semantics. Checked at the same
2467
2352
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2468
2353
  */
2469
2354
  const chatEndRunRequestedKey = locals_js_1.locals.create("chat.endRunRequested");
2355
+ /**
2356
+ * @internal Set by `chat.close()`. Holds the close request (and its reason)
2357
+ * for the rest of the run: the loop writes the terminal `session-closed`
2358
+ * record, closes the session row, and exits at the same post-turn /
2359
+ * pre-wait sites as `chatEndRunRequestedKey`.
2360
+ */
2361
+ const chatCloseRequestedKey = locals_js_1.locals.create("chat.closeRequested");
2362
+ /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2363
+ const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2364
+ /** @internal Set once the session row is closed, so the close happens once. */
2365
+ const chatClosePerformedKey = locals_js_1.locals.create("chat.closePerformed");
2366
+ /**
2367
+ * @internal Set once the terminal `.out` record is written. Tracked apart from
2368
+ * {@link chatClosePerformedKey} so a retried close does not emit a second
2369
+ * client-visible event.
2370
+ */
2371
+ const chatCloseRecordWrittenKey = locals_js_1.locals.create("chat.closeRecordWritten");
2470
2372
  /** @internal */
2471
2373
  const chatAgentCompactionKey = locals_js_1.locals.create("chat.agentCompaction");
2472
2374
  /** @internal */
2473
2375
  const chatPendingMessagesKey = locals_js_1.locals.create("chat.pendingMessages");
2474
2376
  /** @internal */
2475
2377
  const chatSteeringQueueKey = locals_js_1.locals.create("chat.steeringQueue");
2378
+ /**
2379
+ * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2380
+ *
2381
+ * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2382
+ * has to append the messages it injects. Without that, an injected message
2383
+ * reaches the model and the browser but no hook, so an app persisting from
2384
+ * `onTurnComplete` never learns it existed.
2385
+ */
2386
+ const chatTurnNewUIMessagesKey = locals_js_1.locals.create("chat.turnNewUIMessages");
2387
+ /**
2388
+ * Steering messages a drain consumed that the model accumulator has not been
2389
+ * given yet.
2390
+ *
2391
+ * The two accumulators are maintained separately, and the model one is
2392
+ * normally advanced by appending each turn's delta. A drained message is
2393
+ * appended to the UI one but reaches the model only through the `prepareStep`
2394
+ * return value, which is per-step: without this the model lane never learns
2395
+ * the message exists and every later turn of the run answers without it,
2396
+ * while the browser, the snapshot and `chat.history.*` all still show it.
2397
+ *
2398
+ * Held as the messages rather than a "rebuild me" flag because the model lane
2399
+ * can only be appended to, never reconstructed. Compaction replaces it with a
2400
+ * summary and deliberately leaves the UI lane whole, so reconverting the UI
2401
+ * lane restores every message the summary replaced.
2402
+ */
2403
+ const chatPendingSteerKey = locals_js_1.locals.create("chat.pendingSteer");
2476
2404
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2477
2405
  const chatInjectedMessageIdsKey = locals_js_1.locals.create("chat.injectedMessageIds");
2478
2406
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2825,20 +2753,32 @@ function chatCompactionStep(options) {
2825
2753
  return result.type === "skipped" ? undefined : result;
2826
2754
  };
2827
2755
  }
2828
- // ---------------------------------------------------------------------------
2829
- // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2830
- // ---------------------------------------------------------------------------
2756
+ const EMPTY_DRAIN = { injected: [], claimed: [] };
2757
+ /**
2758
+ * The model messages to record for one claimed message. Without `prepare`
2759
+ * each entry's own conversion is used. With it, `prepare` returned one list
2760
+ * for the whole batch, so the first claimed message carries all of it and the
2761
+ * rest carry none, which keeps the total exactly what the model received.
2762
+ */
2763
+ function modelFormOf(m, batch, injected) {
2764
+ return batch[0] === m ? injected : [];
2765
+ }
2831
2766
  /**
2832
2767
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2833
2768
  * pending messages. If it returns true, calls `prepareMessages` once to
2834
2769
  * transform the batch, then clears the queue.
2835
- * Returns the model messages to inject (empty if none).
2770
+ * Returns the model messages to inject and the UI messages actually claimed.
2771
+ *
2772
+ * `claimed` is returned rather than only published to locals because each
2773
+ * surface files it somewhere different: `chat.agent` has an accumulator in
2774
+ * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2775
+ * is silently a no-op for any surface that never set the key.
2836
2776
  * @internal
2837
2777
  */
2838
2778
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2839
2779
  const queue = queueOverride ?? locals_js_1.locals.get(chatSteeringQueueKey);
2840
2780
  if (!queue || queue.length === 0)
2841
- return [];
2781
+ return EMPTY_DRAIN;
2842
2782
  const ctx = locals_js_1.locals.get(chatTurnContextKey);
2843
2783
  const stepNumber = steps.length - 1;
2844
2784
  /**
@@ -2861,7 +2801,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2861
2801
  // Call shouldInject once for the whole batch
2862
2802
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2863
2803
  if (!shouldInject)
2864
- return [];
2804
+ return EMPTY_DRAIN;
2865
2805
  const textOfUIMessage = (m) => (m.parts ?? [])
2866
2806
  .filter((p) => p.type === "text")
2867
2807
  .map((p) => p.text)
@@ -2907,7 +2847,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2907
2847
  queue.splice(at, 1);
2908
2848
  }
2909
2849
  if (claimed.length === 0)
2910
- return [];
2850
+ return EMPTY_DRAIN;
2911
2851
  /**
2912
2852
  * Give the claim back if the transform fails. `prepare` is caller code and
2913
2853
  * can throw; the records have already left the router by this point, so
@@ -2937,6 +2877,37 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2937
2877
  for (const m of claimedUIMessages)
2938
2878
  injectedIds.add(m.id);
2939
2879
  }
2880
+ // Record them as part of the conversation.
2881
+ //
2882
+ // The model has them and the browser has them; without this the
2883
+ // accumulator does not, so they reach neither `uiMessages` nor
2884
+ // `newUIMessages` on `onTurnComplete` and an app that persists from there
2885
+ // silently loses the instruction the answer was shaped by. Appending here
2886
+ // rather than at turn end keeps them in the order they happened: after the
2887
+ // message that started the turn, before the response that answers it.
2888
+ //
2889
+ // De-duplicated by id because a step boundary can drain more than once per
2890
+ // turn, and because a message that failed to inject falls back to becoming
2891
+ // its own turn, where it is accumulated the normal way.
2892
+ const currentUIMessages = locals_js_1.locals.get(chatCurrentUIMessagesKey);
2893
+ const turnNew = locals_js_1.locals.get(chatTurnNewUIMessagesKey);
2894
+ for (const m of claimedUIMessages) {
2895
+ if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
2896
+ currentUIMessages.push(m);
2897
+ }
2898
+ if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
2899
+ turnNew.push(m);
2900
+ }
2901
+ }
2902
+ if (claimedUIMessages.length > 0 && currentUIMessages) {
2903
+ const pendingSteer = locals_js_1.locals.get(chatPendingSteerKey) ?? [];
2904
+ for (const m of claimedUIMessages) {
2905
+ if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
2906
+ pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
2907
+ }
2908
+ }
2909
+ locals_js_1.locals.set(chatPendingSteerKey, pendingSteer);
2910
+ }
2940
2911
  // Write injection confirmation chunk to the stream so the frontend
2941
2912
  // knows which messages were injected and where in the response.
2942
2913
  if (injected.length > 0) {
@@ -2978,7 +2949,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2978
2949
  /* non-fatal */
2979
2950
  }
2980
2951
  }
2981
- return injected;
2952
+ return { injected, claimed: claimedUIMessages };
2982
2953
  }, {
2983
2954
  attributes: {
2984
2955
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3204,32 +3175,149 @@ function buildSkillTools(skills) {
3204
3175
  return { loadSkill, readFile, bash };
3205
3176
  }
3206
3177
  /**
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`.
3178
+ * A `streamText` with the agent's managed options already applied.
3179
+ *
3180
+ * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3181
+ * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3182
+ * because forgetting the spread silently drops the managed prompt, the skill
3183
+ * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3184
+ * conversational injection.
3185
+ *
3186
+ * Caller options win for everything the caller owns (model, messages, signal,
3187
+ * stopWhen). The three that would otherwise clobber managed behaviour are
3188
+ * merged instead of replaced:
3189
+ *
3190
+ * - `tools` are passed into the helper, so skill tools survive.
3191
+ * - `prepareStep` is composed after the managed one, so a caller's per-step
3192
+ * overrides apply on top of steering and compaction instead of disabling them.
3193
+ *
3194
+ * `system` may be set at the call site, on `chat.agent({ system })`, or
3195
+ * through `chat.prompt.set()`, but only in one of them. Setting it in two
3196
+ * places throws: no shape merges two system values on every supported
3197
+ * version, and dropping one silently is the failure this seam exists to
3198
+ * prevent. Injected instructions append to whichever one is in play.
3199
+ */
3200
+ /**
3201
+ * The agent-level managed options (`registry`, `system`, `cacheControl`,
3202
+ * `systemProviderOptions`), published for the run so that
3203
+ * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3204
+ * `streamText` saw them, and the documented spread form silently ran without
3205
+ * the agent's system prompt or model.
3206
+ */
3207
+ const chatAgentManagedConfigKey = locals_js_1.locals.create("chat.agentManagedConfig");
3208
+ /**
3209
+ * The caller's `streamText` options merged with the agent's managed ones.
3214
3210
  *
3215
- * If no prompt has been set, returns `{}` (no-op spread).
3211
+ * Pure, and separate from the call so it can be asserted directly: everything
3212
+ * the caller did not name has to survive the merge, and the way to be sure of
3213
+ * that is to look at the merged object rather than at what the model received.
3216
3214
  */
3215
+ function buildManagedStreamTextOptions(options, config) {
3216
+ const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3217
+ /**
3218
+ * Only the three keys that collide are intercepted. Everything else, telemetry
3219
+ * included, stays in `rest` and reaches `streamText` untouched, with the
3220
+ * caller's value winning because `rest` is spread after `managed`. Pulling a
3221
+ * key out to "handle" it is how a caller's option gets silently dropped.
3222
+ */
3223
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3224
+ const managed = toStreamTextOptions({
3225
+ registry,
3226
+ system: callerSystem ?? agentSystem,
3227
+ cacheControl,
3228
+ systemProviderOptions,
3229
+ /**
3230
+ * A call site that names `tools` replaces the agent's set rather than
3231
+ * adding to it, so narrowing the tools for one call still works. Omitting
3232
+ * `tools` falls back to the agent's, which is what an `onAction`
3233
+ * regenerate needs: without it a regenerated answer can call nothing.
3234
+ */
3235
+ tools: (tools ?? agentTools),
3236
+ });
3237
+ const promptSystem = locals_js_1.locals.get(chatPromptKey)?.text;
3238
+ /**
3239
+ * Two managed sources conflict too, not only a caller against a managed one.
3240
+ * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3241
+ * would win and `chat.agent({ system })` would go nowhere.
3242
+ */
3243
+ if (promptSystem && agentSystem) {
3244
+ throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3245
+ "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3246
+ "place, and add per-turn context with chat.inject({ role: 'system' }).");
3247
+ }
3248
+ const managedSystem = promptSystem || agentSystem;
3249
+ if (callerSystem !== undefined && managedSystem) {
3250
+ throw new Error("chat.agent: `system` is already set " +
3251
+ (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3252
+ ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3253
+ "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3254
+ }
3255
+ const managedPrepareStep = managed.prepareStep;
3256
+ if (typeof callerPrepareStep === "function") {
3257
+ managed.prepareStep = async (arg) => {
3258
+ const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3259
+ const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3260
+ return { ...(first ?? {}), ...(second ?? {}) };
3261
+ };
3262
+ }
3263
+ return { ...managed, ...rest };
3264
+ }
3265
+ /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3266
+ exports.__buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3267
+ function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3268
+ const bound = (options = {}) => (0, ai_runtime_js_1.streamText)(buildManagedStreamTextOptions(options, {
3269
+ registry,
3270
+ system: agentSystem,
3271
+ cacheControl: agentCacheControl,
3272
+ systemProviderOptions: agentSystemProviderOptions,
3273
+ /** Read per call, so per-turn tools resolved after binding are included. */
3274
+ tools: locals_js_1.locals.get(chatResolvedToolsKey),
3275
+ }));
3276
+ return bound;
3277
+ }
3217
3278
  function toStreamTextOptions(options) {
3279
+ const agentDefaults = locals_js_1.locals.get(chatAgentManagedConfigKey);
3280
+ if (agentDefaults) {
3281
+ options = {
3282
+ registry: agentDefaults.registry,
3283
+ system: agentDefaults.system,
3284
+ cacheControl: agentDefaults.cacheControl,
3285
+ systemProviderOptions: agentDefaults.systemProviderOptions,
3286
+ ...options,
3287
+ };
3288
+ }
3218
3289
  const prompt = locals_js_1.locals.get(chatPromptKey);
3219
3290
  const skills = locals_js_1.locals.get(chatSkillsKey);
3220
3291
  const result = {};
3221
3292
  // Build the combined system prompt: stored prompt + skills preamble.
3222
- const promptText = prompt?.text ?? "";
3293
+ const baseSystem = options?.system;
3294
+ const baseSystemText = typeof baseSystem === "string"
3295
+ ? baseSystem
3296
+ : typeof baseSystem?.content === "string"
3297
+ ? baseSystem.content
3298
+ : "";
3299
+ const promptText = prompt?.text || baseSystemText;
3223
3300
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3224
3301
  if (promptText || skillsText) {
3225
3302
  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()`.
3303
+ /**
3304
+ * Resolve system-prompt provider options for caching. Precedence, most
3305
+ * specific first and no deep merge: explicit `systemProviderOptions`, the
3306
+ * `cacheControl` sugar, the ones carried on a structured `system` message,
3307
+ * then whatever `chat.prompt.set()` stored.
3308
+ *
3309
+ * A structured `system` counts only when its own text is the one being
3310
+ * sent. When `chat.prompt.set()` supplied the text, its provider options
3311
+ * are the ones that describe it.
3312
+ */
3313
+ const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3314
+ ? baseSystem.providerOptions
3315
+ : undefined;
3229
3316
  const systemProviderOptions = options?.systemProviderOptions ??
3230
3317
  (options?.cacheControl
3231
3318
  ? { anthropic: { cacheControl: options.cacheControl } }
3232
3319
  : undefined) ??
3320
+ baseSystemProviderOptions ??
3233
3321
  locals_js_1.locals.get(chatPromptProviderOptionsKey);
3234
3322
  // A bare string stays a bare string (the unchanged default). With provider
3235
3323
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3238,6 +3326,88 @@ function toStreamTextOptions(options) {
3238
3326
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3239
3327
  : systemText;
3240
3328
  }
3329
+ /**
3330
+ * Append anything injected as system context, in whichever shape the installed
3331
+ * AI SDK accepts.
3332
+ *
3333
+ * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3334
+ * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3335
+ * range still spans all three, so emitting an array unconditionally would break
3336
+ * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3337
+ * a system message inside `messages` that v7 rejects.
3338
+ *
3339
+ * So: concatenate into one string when the base is a plain string, which every
3340
+ * version accepts and which loses nothing (separate blocks only matter for
3341
+ * per-block `providerOptions`). Use the array form only when the base is already
3342
+ * a structured message — that path requires v6+ regardless, because it is how
3343
+ * prompt caching marks the system block, and flattening it would silently throw
3344
+ * the cache away.
3345
+ *
3346
+ * Either way the injected text goes last: the base prompt keeps its position for
3347
+ * caching, and the addition reads as a later amendment. A changed prefix does
3348
+ * cost the first call its cache hit, on turns that actually injected.
3349
+ */
3350
+ /**
3351
+ * Consumed once per turn, not once per read, and moved out of the lane rather
3352
+ * than marked read in place.
3353
+ *
3354
+ * Per turn, because a `run()` that builds options twice (a cheap classifier
3355
+ * pass and then the answer) has to see the injection in both, and draining on
3356
+ * read hands it to whichever call ran first. Moved out, because blocks left in
3357
+ * the lane sit in front of anything injected during the same turn, and
3358
+ * clearing the lane on the next turn then destroys both. Outside a turn there
3359
+ * is no turn to scope the stash to, so the lane drains on read there.
3360
+ */
3361
+ const injectedInstructions = locals_js_1.locals.get(chatInjectedInstructionsKey);
3362
+ const currentTurn = locals_js_1.locals.get(chatTurnContextKey)?.turn;
3363
+ const consumedThisTurn = currentTurn === undefined ? undefined : locals_js_1.locals.get(chatInstructionsConsumedKey);
3364
+ let injectedBlocks = [];
3365
+ if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3366
+ injectedBlocks = consumedThisTurn.blocks;
3367
+ // Anything injected since the stash was taken joins it, so an instruction
3368
+ // added after an action read the lane still reaches the real turn that
3369
+ // shares the action's turn number, rather than the one after.
3370
+ if (injectedInstructions && injectedInstructions.length > 0) {
3371
+ injectedBlocks.push(...injectedInstructions.splice(0));
3372
+ }
3373
+ }
3374
+ else if (injectedInstructions && injectedInstructions.length > 0) {
3375
+ injectedBlocks = injectedInstructions.splice(0);
3376
+ if (currentTurn !== undefined) {
3377
+ locals_js_1.locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3378
+ }
3379
+ }
3380
+ if (injectedBlocks.length > 0) {
3381
+ const blocks = injectedBlocks;
3382
+ const injectedText = blocks
3383
+ .map((block) => (typeof block.content === "string" ? block.content : ""))
3384
+ .filter(Boolean)
3385
+ .join("\n\n");
3386
+ const base = result.system;
3387
+ if (base === undefined) {
3388
+ result.system = injectedText;
3389
+ }
3390
+ else if (typeof base === "string") {
3391
+ result.system = [base, injectedText].filter(Boolean).join("\n\n");
3392
+ }
3393
+ else {
3394
+ // Merged into the existing block rather than added as a second one. An array
3395
+ // of system blocks would keep the base block's cache entry, but ai@5 rejects
3396
+ // it outright ("Invalid prompt: system must be a string") while accepting a
3397
+ // single structured block, and this package's peer range still spans v5.
3398
+ // Choosing per version would mean resolving the installed version at runtime,
3399
+ // which is not something to build on: `import.meta.url` is illegal in this
3400
+ // package's CommonJS output, and a bundled task may have no resolvable `ai`
3401
+ // to read. One shape that works everywhere beats a cache hit.
3402
+ const baseBlock = base;
3403
+ result.system = {
3404
+ ...baseBlock,
3405
+ content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3406
+ .filter(Boolean)
3407
+ .join("\n\n"),
3408
+ };
3409
+ }
3410
+ }
3241
3411
  // Prompt-related options (only if chat.prompt.set() was called)
3242
3412
  if (prompt) {
3243
3413
  // Resolve model via registry if both are present
@@ -3292,7 +3462,7 @@ function toStreamTextOptions(options) {
3292
3462
  }
3293
3463
  // 2. Pending message injection (steering)
3294
3464
  if (taskPendingMessages) {
3295
- const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3465
+ const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3296
3466
  if (injected.length > 0) {
3297
3467
  resultMessages = [...(resultMessages ?? messages), ...injected];
3298
3468
  }
@@ -3302,18 +3472,89 @@ function toStreamTextOptions(options) {
3302
3472
  if (bgQueue && bgQueue.length > 0) {
3303
3473
  const injected = bgQueue.splice(0); // drain
3304
3474
  resultMessages = [...(resultMessages ?? messages), ...injected];
3475
+ const pendingBackground = locals_js_1.locals.get(chatPendingBackgroundKey) ?? [];
3476
+ pendingBackground.push({
3477
+ afterId: locals_js_1.locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
3478
+ messages: injected,
3479
+ });
3480
+ locals_js_1.locals.set(chatPendingBackgroundKey, pendingBackground);
3305
3481
  }
3306
3482
  return resultMessages ? { messages: resultMessages } : undefined;
3307
3483
  };
3308
3484
  }
3309
3485
  return result;
3310
3486
  }
3487
+ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3488
+ /**
3489
+ * Turn the current action into a turn.
3490
+ *
3491
+ * Return it from `onAction` after editing history. The action's own work is
3492
+ * finished first (the edit is applied and snapshotted), then a turn runs on the
3493
+ * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3494
+ * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3495
+ * advances. That gives the answer everything a turn has, the system prompt,
3496
+ * tools, steering, compaction, injected instructions and persistence, with no
3497
+ * action-specific handling.
3498
+ *
3499
+ * @example
3500
+ * ```ts
3501
+ * onAction: async ({ action }) => {
3502
+ * if (action.type === "regenerate") {
3503
+ * chat.history.slice(0, -1);
3504
+ * return chat.turn();
3505
+ * }
3506
+ * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3507
+ * },
3508
+ * ```
3509
+ */
3510
+ function chatTurn() {
3511
+ return { [actionTurnBrand]: true };
3512
+ }
3513
+ function isActionTurn(value) {
3514
+ return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3515
+ }
3516
+ /**
3517
+ * Replace, in a model lane, the run of messages one UI message contributed.
3518
+ *
3519
+ * Used when a UI message is replaced in place (a tool-approval continuation
3520
+ * merging onto the trailing assistant, a captured response reusing an existing
3521
+ * id, a partial replacing an existing message). Reconverting the whole lane
3522
+ * from the UI lane would also replace a compaction summary with the full
3523
+ * transcript and drop the model forms `pendingMessages.prepare` produced.
3524
+ *
3525
+ * The replaced message is the trailing one, so its run is the lane's tail,
3526
+ * before any steer forms appended after it this turn (`tailAfter`). If the
3527
+ * tail does not match the old message's conversion, nothing is changed and
3528
+ * `false` is returned so the caller can fall back to a full reconversion.
3529
+ */
3530
+ async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3531
+ const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3532
+ const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3533
+ const end = lane.length - tailAfter;
3534
+ const start = end - oldRun.length;
3535
+ if (start < 0 || end > lane.length)
3536
+ return false;
3537
+ if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3538
+ return false;
3539
+ lane.splice(start, oldRun.length, ...newRun);
3540
+ return true;
3541
+ }
3311
3542
  function isUIMessageStreamable(value) {
3312
3543
  return (typeof value === "object" &&
3313
3544
  value !== null &&
3314
3545
  "toUIMessageStream" in value &&
3315
3546
  typeof value.toUIMessageStream === "function");
3316
3547
  }
3548
+ const warnedHydrateMessagesDeprecated = new Set();
3549
+ function warnHydrateMessagesDeprecatedOnce(agentId) {
3550
+ if (warnedHydrateMessagesDeprecated.has(agentId))
3551
+ return;
3552
+ warnedHydrateMessagesDeprecated.add(agentId);
3553
+ console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
3554
+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
3555
+ "lets the application own the model's context, with crash recovery and durable " +
3556
+ "compaction that `hydrateMessages` never had.");
3557
+ }
3317
3558
  let warnedMissingOnAction = false;
3318
3559
  function warnMissingOnActionOnce() {
3319
3560
  if (warnedMissingOnAction)
@@ -3446,10 +3687,22 @@ function chatCustomAgent(options) {
3446
3687
  await installChatInputRouter(payload.chatId, {
3447
3688
  resuming: Boolean(payload.continuation),
3448
3689
  });
3690
+ // A custom agent's loop is the customer's, so there is no exit site the
3691
+ // SDK controls. Perform a requested close when `run()` returns, whatever
3692
+ // shape the loop had. Idempotent, so the createSession iterator having
3693
+ // already closed on its own exit costs nothing.
3694
+ const withClose = async (result) => {
3695
+ try {
3696
+ return await result;
3697
+ }
3698
+ finally {
3699
+ await performChatClose();
3700
+ }
3701
+ };
3449
3702
  // Keep the schema-free path identical to the original custom-agent
3450
3703
  // wrapper, including when userRun starts executing.
3451
3704
  if (!parseClientData) {
3452
- return userRun(payload, runOptions);
3705
+ return withClose(userRun(payload, runOptions));
3453
3706
  }
3454
3707
  const isHandoverBoot = payload.trigger === "handover-prepare";
3455
3708
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3465,7 +3718,7 @@ function chatCustomAgent(options) {
3465
3718
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3466
3719
  });
3467
3720
  if (validated.ok) {
3468
- return userRun(validated.payload, runOptions);
3721
+ return withClose(userRun(validated.payload, runOptions));
3469
3722
  }
3470
3723
  if (isHandoverBoot) {
3471
3724
  const signal = await waitForHandover({
@@ -3501,7 +3754,7 @@ function chatCustomAgent(options) {
3501
3754
  sessionId: next.output.sessionId ?? payload.sessionId,
3502
3755
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3503
3756
  };
3504
- return userRun(recoveredPayload, runOptions);
3757
+ return withClose(userRun(recoveredPayload, runOptions));
3505
3758
  },
3506
3759
  });
3507
3760
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3514,7 +3767,19 @@ function chatCustomAgent(options) {
3514
3767
  return task;
3515
3768
  }
3516
3769
  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;
3770
+ 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;
3771
+ if (hydrateMessages) {
3772
+ if (storage) {
3773
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3774
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3775
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3776
+ }
3777
+ if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
3778
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3779
+ "`loadContext`. Both would own the model's context; keep one.");
3780
+ }
3781
+ warnHydrateMessagesDeprecatedOnce(options.id);
3782
+ }
3518
3783
  const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3519
3784
  const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
3520
3785
  // chat.agent does not expose generic retry options (see docstring on
@@ -3597,6 +3862,42 @@ function chatAgent(options) {
3597
3862
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3598
3863
  // registered) — the wire is delta-only now, no longer a seed.
3599
3864
  let accumulatedMessages = [];
3865
+ /**
3866
+ * Give the model accumulator the background injections a step-boundary
3867
+ * drain handed to the model this turn, and record them for persistence.
3868
+ * Returns how many model messages were appended.
3869
+ */
3870
+ const reconcilePendingBackground = () => {
3871
+ const pending = locals_js_1.locals.get(chatPendingBackgroundKey);
3872
+ if (!pending || pending.length === 0)
3873
+ return 0;
3874
+ locals_js_1.locals.set(chatPendingBackgroundKey, []);
3875
+ let appended = 0;
3876
+ for (const entry of pending) {
3877
+ accumulatedMessages.push(...entry.messages);
3878
+ laneInjections.push(entry);
3879
+ appended += entry.messages.length;
3880
+ }
3881
+ return appended;
3882
+ };
3883
+ /**
3884
+ * Give the model accumulator the steering messages a drain consumed,
3885
+ * in the form the model actually received. Appended, never reconverted
3886
+ * from the UI lane, so a model-only compaction summary survives. Called
3887
+ * on both the success and the error path, before the response or the
3888
+ * partial joins the lane, so the order stays steer-then-answer.
3889
+ */
3890
+ const reconcilePendingSteer = (options) => {
3891
+ const pending = locals_js_1.locals.get(chatPendingSteerKey);
3892
+ if (!pending || pending.length === 0)
3893
+ return [];
3894
+ locals_js_1.locals.set(chatPendingSteerKey, []);
3895
+ for (const entry of pending) {
3896
+ accumulatedMessages.push(...entry.model);
3897
+ options?.turnNew?.push(...entry.model);
3898
+ }
3899
+ return pending;
3900
+ };
3600
3901
  // Accumulated UI messages for persistence. Mirrors the model accumulator
3601
3902
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
3602
3903
  let accumulatedUIMessages = [];
@@ -3614,7 +3915,156 @@ function chatAgent(options) {
3614
3915
  // collectively cost ~600ms on every first-message TTFC. Both reads
3615
3916
  // swallow errors internally; the agent stays available either way.
3616
3917
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
3918
+ const transcriptStorage = storage ??
3919
+ transcriptStorageOverride ??
3920
+ transcriptStorage_js_1.defaultStorage;
3921
+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
3922
+ /**
3923
+ * Who supplies the model's context each turn: the deprecated
3924
+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
3925
+ * the runtime's own transcript.
3926
+ */
3927
+ const loadContextHook = hydrateMessages
3928
+ ? (event) => hydrateMessages(event)
3929
+ : storageLoadContext
3930
+ ? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
3931
+ : undefined;
3932
+ let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
3933
+ let bootTranscriptState = null;
3934
+ /**
3935
+ * True while the model lane holds a compaction summary, so it cannot be
3936
+ * rebuilt from the transcript and has to be persisted as state. Reset
3937
+ * wherever the lane is reconverted from the UI lane.
3938
+ */
3939
+ let laneCompacted = false;
3940
+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
3941
+ let laneInjections = [];
3942
+ let persistedStateSet = false;
3617
3943
  let bootSnapshot;
3944
+ let bootClientData = payload.metadata;
3945
+ if (parseClientData) {
3946
+ try {
3947
+ bootClientData = await parseClientData(payload.metadata);
3948
+ }
3949
+ catch {
3950
+ bootClientData = payload.metadata;
3951
+ }
3952
+ }
3953
+ /**
3954
+ * The `lastOutEventId` the most recent snapshot carried.
3955
+ *
3956
+ * A snapshot written outside a turn — after an action mutates history — has
3957
+ * no turn cursor of its own, and writing `undefined` there would drop the
3958
+ * resume point and make the next boot replay from further back. Retaining it
3959
+ * keeps an action's write cursor-neutral.
3960
+ */
3961
+ let lastSnapshotOutEventId;
3962
+ const storageTrigger = (trigger) => trigger === "regenerate-message"
3963
+ ? "regenerate-message"
3964
+ : trigger === "action" || trigger === "action-turn"
3965
+ ? "action"
3966
+ : "submit-message";
3967
+ /**
3968
+ * Hand the runtime's view of the transcript to the storage as a
3969
+ * changeset: the diff against what was last saved, plus the cursors the
3970
+ * next boot resumes from. The shadow only advances when the save
3971
+ * succeeds, so a failed save is folded into the next changeset.
3972
+ */
3973
+ /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
3974
+ let transcriptState = null;
3975
+ const saveTranscript = async (opts) => {
3976
+ const { changes, shadow } = (0, transcriptStorage_js_1.diffTranscript)(transcriptShadow, opts.messages, {
3977
+ nonFinalIds: opts.nonFinalIds,
3978
+ });
3979
+ const throughId = opts.messages.at(-1)?.id ?? "";
3980
+ const queued = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
3981
+ const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
3982
+ ? {
3983
+ v: 1,
3984
+ ...(laneCompacted
3985
+ ? {
3986
+ compaction: {
3987
+ modelMessages: accumulatedMessages,
3988
+ throughId,
3989
+ },
3990
+ }
3991
+ : {}),
3992
+ ...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
3993
+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
3994
+ }
3995
+ : null;
3996
+ if (runtimeState !== null || persistedStateSet) {
3997
+ changes.push({ op: "state", value: runtimeState });
3998
+ }
3999
+ transcriptState = runtimeState;
4000
+ const inCursor = chatInputRouter().resumeFloor();
4001
+ await transcriptStorage.save({
4002
+ chatId: payload.chatId,
4003
+ clientData: opts.clientData,
4004
+ turn: opts.turn,
4005
+ trigger: opts.trigger,
4006
+ runId: ctx.run.id,
4007
+ ctx,
4008
+ }, {
4009
+ reason: opts.reason,
4010
+ changes,
4011
+ transcript: {
4012
+ entries: opts.messages.map((message) => ({
4013
+ id: message.id,
4014
+ final: !shadow.nonFinal.has(message.id),
4015
+ message,
4016
+ })),
4017
+ state: transcriptState,
4018
+ },
4019
+ cursors: {
4020
+ lastOutEventId: opts.lastOutEventId,
4021
+ lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
4022
+ },
4023
+ });
4024
+ transcriptShadow = shadow;
4025
+ persistedStateSet = runtimeState !== null;
4026
+ };
4027
+ /**
4028
+ * Persist the accumulator outside a turn.
4029
+ *
4030
+ * An action is not a turn, so it never reaches the turn-complete path where
4031
+ * the transcript is normally saved, but a `chat.history` mutation changes
4032
+ * the conversation and has to survive the run ending.
4033
+ *
4034
+ * Cursor-neutral: an action has no turn cursor of its own, and writing
4035
+ * `undefined` would drop the resume point the last turn established and make
4036
+ * the next boot replay from further back.
4037
+ */
4038
+ const writeSnapshotOutsideTurn = async (reason, turnContext) => {
4039
+ if (hydrateMessages)
4040
+ return;
4041
+ try {
4042
+ await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
4043
+ await saveTranscript({
4044
+ reason: "action",
4045
+ messages: accumulatedUIMessages,
4046
+ turn: turnContext.turn,
4047
+ trigger: "action",
4048
+ clientData: turnContext.clientData,
4049
+ lastOutEventId: lastSnapshotOutEventId,
4050
+ });
4051
+ }, {
4052
+ attributes: {
4053
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4054
+ [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
4055
+ "chat.snapshot.reason": reason,
4056
+ "chat.messages.count": accumulatedUIMessages.length,
4057
+ },
4058
+ });
4059
+ }
4060
+ catch (error) {
4061
+ v3_1.logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4062
+ error: error instanceof Error ? error.message : String(error),
4063
+ sessionId: sessionIdForSnapshot,
4064
+ reason,
4065
+ });
4066
+ }
4067
+ };
3618
4068
  let replayedSettled = [];
3619
4069
  let replayedPartial;
3620
4070
  let replayedPartialRaw;
@@ -3630,7 +4080,7 @@ function chatAgent(options) {
3630
4080
  // resume-cursor block reuses it instead of re-scanning.
3631
4081
  let bootInCursor;
3632
4082
  let bootInCursorResolved = false;
3633
- if (!hydrateMessages && couldHavePriorState) {
4083
+ if (couldHavePriorState) {
3634
4084
  // Single parent span for the whole boot read phase — snapshot
3635
4085
  // read, session.out replay, session.in replay. Per-phase timing
3636
4086
  // + result counts are attributes on the span.
@@ -3638,18 +4088,33 @@ function chatAgent(options) {
3638
4088
  // snapshot read
3639
4089
  const snapStart = Date.now();
3640
4090
  try {
3641
- bootSnapshot = await readChatSnapshot(sessionIdForSnapshot);
4091
+ const loaded = hydrateMessages
4092
+ ? undefined
4093
+ : await transcriptStorage.load({
4094
+ chatId: payload.chatId,
4095
+ clientData: bootClientData,
4096
+ });
4097
+ if (loaded) {
4098
+ transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)(loaded.messages, new Set(loaded.nonFinalIds ?? []));
4099
+ bootTranscriptState = loaded.state;
4100
+ transcriptState = loaded.state ?? null;
4101
+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
4102
+ bootSnapshot = {
4103
+ messages: loaded.messages,
4104
+ lastOutEventId: loaded.cursors?.lastOutEventId,
4105
+ lastInEventId: loaded.cursors?.lastInEventId,
4106
+ };
4107
+ }
3642
4108
  }
3643
4109
  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", {
4110
+ v3_1.logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
3647
4111
  error: error instanceof Error ? error.message : String(error),
3648
4112
  sessionId: sessionIdForSnapshot,
3649
4113
  });
3650
4114
  }
3651
4115
  bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
3652
- bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot);
4116
+ bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
4117
+ (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
3653
4118
  bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
3654
4119
  // Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
3655
4120
  // id of the previous turn's `turn-complete` control record). The
@@ -3657,6 +4122,7 @@ function chatAgent(options) {
3657
4122
  // Without seeding, the new worker would emit no trim on its first
3658
4123
  // turn (chain self-bootstraps from turn 2), so this is purely an
3659
4124
  // optimization to keep continuation runs bounded from the first turn.
4125
+ lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
3660
4126
  if (bootSnapshot?.lastOutEventId !== undefined) {
3661
4127
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
3662
4128
  if (Number.isFinite(seeded)) {
@@ -3751,13 +4217,17 @@ function chatAgent(options) {
3751
4217
  // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3752
4218
  // only a fallback: the boot block above may already have resolved a
3753
4219
  // cursor from the snapshot, which is used when the boundary itself
3754
- // carries none.
4220
+ // carries none. Everything the boot replayed off `.in` is dispatched from
4221
+ // `bootInjectedQueue` below, so it goes into the floor here — folded in
4222
+ // after the subscription opens, the live tail re-delivers it as a turn.
4223
+ const lastRecoveredInSeq = replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1].seqNum : undefined;
3755
4224
  await installChatInputRouter(payload.chatId, {
3756
4225
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4226
+ recoveredThrough: lastRecoveredInSeq,
3757
4227
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3758
4228
  });
3759
4229
  // ── Recovery boot + chain reconstruction ────────────────────────
3760
- if (!hydrateMessages) {
4230
+ {
3761
4231
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
3762
4232
  const inFlightUsers = replayedInTail.map((r) => r.message);
3763
4233
  const partialAssistant = replayedPartial;
@@ -3861,16 +4331,6 @@ function chatAgent(options) {
3861
4331
  if (hookBeforeBoot) {
3862
4332
  await hookBeforeBoot();
3863
4333
  }
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
4334
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
3875
4335
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
3876
4336
  // dispatch as normal turns with the existing hook stack.
@@ -3920,7 +4380,8 @@ function chatAgent(options) {
3920
4380
  // path where wire-borne UIMessage[] still seeds the accumulator,
3921
4381
  // and it's safe because the route handler isn't subject to the
3922
4382
  // `/in/append` 512 KiB cap.
3923
- if (accumulatedUIMessages.length === 0 &&
4383
+ if (!loadContextHook &&
4384
+ accumulatedUIMessages.length === 0 &&
3924
4385
  payload.trigger === "handover-prepare" &&
3925
4386
  Array.isArray(payload.headStartMessages) &&
3926
4387
  payload.headStartMessages.length > 0) {
@@ -3949,7 +4410,17 @@ function chatAgent(options) {
3949
4410
  }
3950
4411
  }
3951
4412
  try {
3952
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
4413
+ const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
4414
+ const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4415
+ accumulatedMessages = restored.messages;
4416
+ laneCompacted = restored.compacted;
4417
+ laneInjections = restored.injections;
4418
+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4419
+ locals_js_1.locals.set(chatBackgroundQueueKey, [
4420
+ ...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
4421
+ ...bootRuntimeState.queued,
4422
+ ]);
4423
+ }
3953
4424
  }
3954
4425
  catch (error) {
3955
4426
  v3_1.logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
@@ -3963,6 +4434,12 @@ function chatAgent(options) {
3963
4434
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
3964
4435
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3965
4436
  }
4437
+ locals_js_1.locals.set(chatAgentManagedConfigKey, {
4438
+ registry: promptRegistry,
4439
+ system: agentSystem,
4440
+ cacheControl: agentCacheControl,
4441
+ systemProviderOptions: agentSystemProviderOptions,
4442
+ });
3966
4443
  // Token usage tracking across turns
3967
4444
  let previousTurnUsage;
3968
4445
  let cumulativeUsage = emptyUsage();
@@ -4320,6 +4797,7 @@ function chatAgent(options) {
4320
4797
  } // end else (no boot-injected first turn)
4321
4798
  }
4322
4799
  for (let turn = 0; turn < maxTurns; turn++) {
4800
+ let turnClientData = payload.metadata;
4323
4801
  // Declared here so the finally can detach it — a handler leaked past
4324
4802
  // its turn duplicates every mid-stream message into the shared buffer.
4325
4803
  let turnMsgSub;
@@ -4341,6 +4819,7 @@ function chatAgent(options) {
4341
4819
  // `cleanupAbortedParts` no longer has to be re-applied below.
4342
4820
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4343
4821
  const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4822
+ turnClientData = clientData;
4344
4823
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
4345
4824
  // Actions are not turns. They use a different span name
4346
4825
  // and don't carry a turn.number. Branched on at `isAction`.
@@ -4377,6 +4856,7 @@ function chatAgent(options) {
4377
4856
  locals_js_1.locals.set(chatDeferKey, new Set());
4378
4857
  locals_js_1.locals.set(chatCompactionStateKey, undefined);
4379
4858
  locals_js_1.locals.set(chatSteeringQueueKey, []);
4859
+ locals_js_1.locals.set(chatPendingBackgroundKey, []);
4380
4860
  locals_js_1.locals.set(chatResponsePartsKey, []);
4381
4861
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
4382
4862
  // by deferred work from the previous turn's onTurnComplete need to
@@ -4458,6 +4938,10 @@ function chatAgent(options) {
4458
4938
  // Track new messages for this turn (user input + assistant response).
4459
4939
  const turnNewModelMessages = [];
4460
4940
  const turnNewUIMessages = [];
4941
+ locals_js_1.locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
4942
+ // A head-start handover deliberately resumes from an assistant
4943
+ // message it spliced in, so it isn't a no-op turn.
4944
+ let splicedHandoverPartial = false;
4461
4945
  // ── Action handling ──────────────────────────────────────
4462
4946
  // Actions arrive on the same input stream but with
4463
4947
  // trigger === "action". They are NOT turns — only
@@ -4468,16 +4952,25 @@ function chatAgent(options) {
4468
4952
  // an action, return a `StreamTextResult` (auto-piped),
4469
4953
  // string, or UIMessage from `onAction`. Turn counter
4470
4954
  // does not advance.
4471
- let actionStreamResult = undefined;
4955
+ let actionResult = undefined;
4956
+ /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
4957
+ let actionTurn = false;
4958
+ /**
4959
+ * Whether this action changed the conversation, by rolling history
4960
+ * back or by streaming a response. Drives the single snapshot write
4961
+ * at the end — an action never reaches the turn-complete path that
4962
+ * normally does it.
4963
+ */
4964
+ let actionChangedHistory = false;
4472
4965
  if (isAction) {
4473
4966
  // Parse and validate the action payload
4474
4967
  const parsedAction = parseAction
4475
4968
  ? await parseAction(currentWirePayload.action)
4476
4969
  : currentWirePayload.action;
4477
4970
  // Hydrate messages from backend if configured
4478
- if (hydrateMessages) {
4971
+ if (loadContextHook) {
4479
4972
  const hydrated = await tracer_js_1.tracer.startActiveSpan("hydrateMessages()", async () => {
4480
- return hydrateMessages({
4973
+ return loadContextHook({
4481
4974
  chatId: currentWirePayload.chatId,
4482
4975
  turn,
4483
4976
  trigger: "action",
@@ -4497,12 +4990,14 @@ function chatAgent(options) {
4497
4990
  });
4498
4991
  accumulatedUIMessages = [...hydrated];
4499
4992
  accumulatedMessages = await toModelMessages(hydrated);
4993
+ laneCompacted = false;
4994
+ laneInjections = [];
4500
4995
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4501
4996
  }
4502
4997
  // Fire onAction — handler may mutate state via
4503
4998
  // `chat.history.*` and / or return a model response.
4504
4999
  if (onAction) {
4505
- actionStreamResult = await tracer_js_1.tracer.startActiveSpan("onAction()", async () => {
5000
+ actionResult = await tracer_js_1.tracer.startActiveSpan("onAction()", async () => {
4506
5001
  return await onAction({
4507
5002
  action: parsedAction,
4508
5003
  chatId: currentWirePayload.chatId,
@@ -4527,7 +5022,10 @@ function chatAgent(options) {
4527
5022
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
4528
5023
  accumulatedUIMessages = [...actionOverride];
4529
5024
  accumulatedMessages = await toModelMessages(actionOverride);
5025
+ laneCompacted = false;
5026
+ laneInjections = [];
4530
5027
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5028
+ actionChangedHistory = true;
4531
5029
  }
4532
5030
  }
4533
5031
  else {
@@ -4548,7 +5046,7 @@ function chatAgent(options) {
4548
5046
  // route handler's first-turn history to the hydrate hook as
4549
5047
  // incoming messages instead (gated on the pending handover).
4550
5048
  if (turn === 0 &&
4551
- hydrateMessages &&
5049
+ loadContextHook &&
4552
5050
  cleanedUIMessages.length === 0 &&
4553
5051
  (locals_js_1.locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
4554
5052
  Array.isArray(payload.headStartMessages) &&
@@ -4578,7 +5076,7 @@ function chatAgent(options) {
4578
5076
  },
4579
5077
  }));
4580
5078
  }
4581
- if (hydrateMessages) {
5079
+ if (loadContextHook) {
4582
5080
  // Snapshot the ids the accumulator knew BEFORE this
4583
5081
  // turn ran — used below to decide whether an
4584
5082
  // incoming wire message is genuinely new or just a
@@ -4596,7 +5094,7 @@ function chatAgent(options) {
4596
5094
  // was always true for `submit-message` is now true for every
4597
5095
  // trigger.
4598
5096
  const hydrated = await tracer_js_1.tracer.startActiveSpan("hydrateMessages()", async () => {
4599
- return hydrateMessages({
5097
+ return loadContextHook({
4600
5098
  chatId: currentWirePayload.chatId,
4601
5099
  turn,
4602
5100
  trigger: currentWirePayload.trigger,
@@ -4635,6 +5133,8 @@ function chatAgent(options) {
4635
5133
  }
4636
5134
  accumulatedUIMessages = merged;
4637
5135
  accumulatedMessages = await toModelMessages(merged);
5136
+ laneCompacted = false;
5137
+ laneInjections = [];
4638
5138
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4639
5139
  // Track new messages for onTurnComplete.newUIMessages.
4640
5140
  // Only push for genuinely new ids — HITL continuations
@@ -4680,6 +5180,8 @@ function chatAgent(options) {
4680
5180
  accumulatedUIMessages.pop();
4681
5181
  }
4682
5182
  accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5183
+ laneCompacted = false;
5184
+ laneInjections = [];
4683
5185
  }
4684
5186
  else if (cleanedUIMessages.length > 0) {
4685
5187
  // Submit-message (and the special-cased
@@ -4696,6 +5198,7 @@ function chatAgent(options) {
4696
5198
  // where AI SDK regenerates the id (TRI-9137) still
4697
5199
  // applies via `rewriteIncomingIdViaToolCallMap`.
4698
5200
  let replaced = false;
5201
+ const replacedPairs = [];
4699
5202
  for (const raw of cleanedUIMessages) {
4700
5203
  let incoming = raw;
4701
5204
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -4707,7 +5210,9 @@ function chatAgent(options) {
4707
5210
  }
4708
5211
  }
4709
5212
  if (idx !== -1) {
4710
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5213
+ const previous = accumulatedUIMessages[idx];
5214
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5215
+ replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4711
5216
  replaced = true;
4712
5217
  }
4713
5218
  else {
@@ -4717,9 +5222,19 @@ function chatAgent(options) {
4717
5222
  recordToolCallIdsFromMessage(incoming);
4718
5223
  }
4719
5224
  if (replaced) {
4720
- // Replacement changes structure — reconvert all model
4721
- // messages instead of appending.
4722
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5225
+ let inPlace = true;
5226
+ for (const { previous, merged } of replacedPairs) {
5227
+ if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5228
+ inPlace = false;
5229
+ break;
5230
+ }
5231
+ }
5232
+ if (!inPlace) {
5233
+ v3_1.logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5234
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5235
+ laneCompacted = false;
5236
+ laneInjections = [];
5237
+ }
4723
5238
  }
4724
5239
  else {
4725
5240
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -4762,10 +5277,33 @@ function chatAgent(options) {
4762
5277
  messageId: locals_js_1.locals.get(chatHandoverMessageIdKey),
4763
5278
  });
4764
5279
  locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
5280
+ splicedHandoverPartial = true;
4765
5281
  }
4766
5282
  }
4767
5283
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4768
5284
  } // end if (trigger !== "action")
5285
+ // ── No-op turn ──────────────────────────────────────────
5286
+ //
5287
+ // A submit that added no new user message and leaves the model
5288
+ // chain ending on an assistant message has nothing to answer —
5289
+ // calling the model would prefill its own last reply. Keyed on
5290
+ // the model tail, so a `tool`-terminated chain (a merged tool
5291
+ // approval) still runs.
5292
+ const isNoOpTurn = !isAction &&
5293
+ !splicedHandoverPartial &&
5294
+ currentWirePayload.trigger === "submit-message" &&
5295
+ turnNewUIMessages.length === 0 &&
5296
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5297
+ if (isNoOpTurn) {
5298
+ msgSub?.off();
5299
+ v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
5300
+ chatId: currentWirePayload.chatId,
5301
+ messageId: currentWirePayload.messageId,
5302
+ });
5303
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5304
+ // Not a turn — don't consume an iteration.
5305
+ turn--;
5306
+ }
4769
5307
  // ── Action result handling ──────────────────────────────
4770
5308
  // For action turns, skip the turn machinery entirely.
4771
5309
  // If `onAction` returned a stream / string / UIMessage,
@@ -4775,34 +5313,34 @@ function chatAgent(options) {
4775
5313
  // The turn counter is decremented so the next iteration
4776
5314
  // sees the same `turn` value — actions don't count.
4777
5315
  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
- });
5316
+ if (isActionTurn(actionResult)) {
5317
+ // Persist the edit before the turn starts, so a turn that is
5318
+ // cancelled or runs out of memory continues from the edited
5319
+ // history rather than from the snapshot the edit replaced.
5320
+ // The turn then does its own hooks, completion and snapshot.
5321
+ if (actionChangedHistory) {
5322
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4791
5323
  }
4792
- catch (error) {
4793
- if (error instanceof Error &&
4794
- error.name === "AbortError" &&
4795
- runSignal.aborted) {
4796
- return "exit";
4797
- }
4798
- throw error;
5324
+ actionTurn = true;
5325
+ }
5326
+ else if (actionResult !== undefined) {
5327
+ throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5328
+ "after the edit, return chat.turn() and a turn runs on the edited history. " +
5329
+ "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5330
+ }
5331
+ else {
5332
+ msgSub?.off();
5333
+ if (actionChangedHistory) {
5334
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4799
5335
  }
5336
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5337
+ // Don't consume a turn iteration — actions aren't turns.
5338
+ turn--;
4800
5339
  }
4801
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4802
- // Don't consume a turn iteration — actions aren't turns.
4803
- turn--;
4804
5340
  }
4805
- if (!isAction) {
5341
+ // A no-op turn skips this block, and with it `followSessionPin`:
5342
+ // there is nothing to answer, so nothing to hand over.
5343
+ if ((!isAction || actionTurn) && !isNoOpTurn) {
4806
5344
  // Mint a scoped public access token once per turn, reused for
4807
5345
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
4808
5346
  const currentRunId = ctx.run.id;
@@ -4891,6 +5429,8 @@ function chatAgent(options) {
4891
5429
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
4892
5430
  accumulatedUIMessages = [...turnStartOverride];
4893
5431
  accumulatedMessages = await toModelMessages(turnStartOverride);
5432
+ laneCompacted = false;
5433
+ laneInjections = [];
4894
5434
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4895
5435
  }
4896
5436
  }, {
@@ -4907,9 +5447,11 @@ function chatAgent(options) {
4907
5447
  },
4908
5448
  });
4909
5449
  }
5450
+ await followSessionPin(currentWirePayload.chatId, versionSkew);
4910
5451
  // 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.
5452
+ // skip run() and hand over to a fresh run on the new version. The
5453
+ // successor picks the message up off session.in; the transport only
5454
+ // keeps reading.
4913
5455
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
4914
5456
  await writeUpgradeRequiredChunk();
4915
5457
  return "exit";
@@ -4949,7 +5491,12 @@ function chatAgent(options) {
4949
5491
  const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
4950
5492
  const bgQueue = locals_js_1.locals.get(chatBackgroundQueueKey);
4951
5493
  if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
4952
- accumulatedMessages.push(...bgQueue.splice(0));
5494
+ const injected = bgQueue.splice(0);
5495
+ accumulatedMessages.push(...injected);
5496
+ laneInjections.push({
5497
+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
5498
+ messages: injected,
5499
+ });
4953
5500
  }
4954
5501
  if (isHeadStartFinalTurn) {
4955
5502
  // The synthesized partial UIMessage IS the response.
@@ -4968,6 +5515,9 @@ function chatAgent(options) {
4968
5515
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
4969
5516
  runResult = await userRun({
4970
5517
  ...restWire,
5518
+ // A turn requested by chat.turn() is not the action itself:
5519
+ // a run() that short-circuits on "action" must still answer.
5520
+ ...(actionTurn ? { trigger: "action-turn" } : {}),
4971
5521
  messages: preparedMessages,
4972
5522
  clientData,
4973
5523
  continuation,
@@ -4980,6 +5530,7 @@ function chatAgent(options) {
4980
5530
  signal: combinedSignal,
4981
5531
  cancelSignal,
4982
5532
  stopSignal,
5533
+ streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
4983
5534
  });
4984
5535
  }
4985
5536
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5093,7 +5644,21 @@ function chatAgent(options) {
5093
5644
  if (runOverride) {
5094
5645
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5095
5646
  accumulatedUIMessages = [...runOverride];
5096
- accumulatedMessages = await toModelMessages(runOverride);
5647
+ /**
5648
+ * Steers the drain consumed are left out of the rebuild and
5649
+ * appended by the reconciliation below instead, so the lane
5650
+ * gets the form the model actually received rather than a
5651
+ * reconversion of the UI message, and gets it once. A steer
5652
+ * the edit removed is dropped from the pending list too, so
5653
+ * the edit is honoured.
5654
+ */
5655
+ const overrideIds = new Set(runOverride.map((m) => m.id));
5656
+ const pending = (locals_js_1.locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5657
+ locals_js_1.locals.set(chatPendingSteerKey, pending);
5658
+ const pendingIds = new Set(pending.map((e) => e.ui.id));
5659
+ accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5660
+ laneCompacted = false;
5661
+ laneInjections = [];
5097
5662
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5098
5663
  }
5099
5664
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5116,6 +5681,8 @@ function chatAgent(options) {
5116
5681
  accumulatedMessages = taskCompactionConfig?.compactModelMessages
5117
5682
  ? await taskCompactionConfig.compactModelMessages(compactEvent)
5118
5683
  : modelOnlyOverride;
5684
+ laneCompacted = true;
5685
+ laneInjections = [];
5119
5686
  // Apply UI messages: callback or default (preserve all)
5120
5687
  if (taskCompactionConfig?.compactUIMessages) {
5121
5688
  accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
@@ -5123,10 +5690,20 @@ function chatAgent(options) {
5123
5690
  }
5124
5691
  // Determine if the user stopped generation this turn (not a full run cancel).
5125
5692
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5693
+ // Give the model accumulator the steering messages the drain
5694
+ // consumed. Appended, never reconverted from the UI lane, so a
5695
+ // model-only compaction summary set just above survives; and done
5696
+ // before the response is appended so the order stays
5697
+ // steer-then-answer. Outside the `capturedResponseMessage`
5698
+ // branches below, so a turn that captured no response is covered.
5699
+ const steerTailThisTurn = reconcilePendingSteer({
5700
+ turnNew: turnNewModelMessages,
5701
+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
5126
5702
  // Append the assistant's response (partial or complete) to the accumulator.
5127
5703
  // The onFinish callback fires even on abort/stop, so partial responses
5128
5704
  // from stopped generation are captured correctly.
5129
5705
  let rawResponseMessage;
5706
+ let responseWasSkipped = false;
5130
5707
  if (capturedResponseMessage) {
5131
5708
  // Keep the raw message before cleanup for users who want custom handling
5132
5709
  rawResponseMessage = capturedResponseMessage;
@@ -5152,43 +5729,56 @@ function chatAgent(options) {
5152
5729
  };
5153
5730
  locals_js_1.locals.set(chatResponsePartsKey, []);
5154
5731
  }
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
- ]);
5732
+ const responseHasContent = capturedResponseMessage.parts.some((part) => part.type !== "step-start");
5733
+ if (responseHasContent) {
5734
+ // Tool-approval continuations: the AI SDK reuses the trailing
5735
+ // assistant's ID (via originalMessages) so the captured response
5736
+ // carries the same ID as an existing message. Replace in place
5737
+ // instead of pushing a duplicate. For action turns this never
5738
+ // matches because originalMessages is omitted (fresh ID).
5739
+ const existingIdx = capturedResponseMessage.id
5740
+ ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5741
+ : -1;
5742
+ const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5181
5743
  if (existingIdx !== -1) {
5182
- // Reconvert all model messages since we replaced rather than appended
5183
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5744
+ accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5184
5745
  }
5185
5746
  else {
5186
- accumulatedMessages.push(...responseModelMessages);
5747
+ accumulatedUIMessages.push(capturedResponseMessage);
5748
+ }
5749
+ turnNewUIMessages.push(capturedResponseMessage);
5750
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5751
+ // Record toolCallId → head messageId so a HITL
5752
+ // continuation next turn can recover the head id
5753
+ // even if the AI SDK regenerates it. See
5754
+ // `chatToolCallToMessageIdKey` for the full
5755
+ // rationale (TRI-9137).
5756
+ recordToolCallIdsFromMessage(capturedResponseMessage);
5757
+ try {
5758
+ const responseModelMessages = await toModelMessages([
5759
+ stripProviderMetadata(capturedResponseMessage),
5760
+ ]);
5761
+ if (existingIdx !== -1) {
5762
+ const ok = previousAtIdx !== undefined &&
5763
+ (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5764
+ if (!ok) {
5765
+ v3_1.logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5766
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5767
+ laneCompacted = false;
5768
+ laneInjections = [];
5769
+ }
5770
+ }
5771
+ else {
5772
+ accumulatedMessages.push(...responseModelMessages);
5773
+ }
5774
+ turnNewModelMessages.push(...responseModelMessages);
5775
+ }
5776
+ catch {
5777
+ // Conversion failed — skip accumulation for this turn
5187
5778
  }
5188
- turnNewModelMessages.push(...responseModelMessages);
5189
5779
  }
5190
- catch {
5191
- // Conversion failed — skip accumulation for this turn
5780
+ else {
5781
+ responseWasSkipped = true;
5192
5782
  }
5193
5783
  }
5194
5784
  // If there's no captured response (manual pipe mode) but there are
@@ -5288,6 +5878,8 @@ function chatAgent(options) {
5288
5878
  ],
5289
5879
  },
5290
5880
  ];
5881
+ laneCompacted = true;
5882
+ laneInjections = [];
5291
5883
  // UI messages: callback or default (preserve all)
5292
5884
  if (outerCompaction.compactUIMessages) {
5293
5885
  accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
@@ -5377,6 +5969,8 @@ function chatAgent(options) {
5377
5969
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5378
5970
  accumulatedUIMessages = [...override];
5379
5971
  accumulatedMessages = await toModelMessages(override);
5972
+ laneCompacted = false;
5973
+ laneInjections = [];
5380
5974
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5381
5975
  // Update event so onTurnComplete sees compacted messages
5382
5976
  turnCompleteEvent.messages = accumulatedMessages;
@@ -5405,6 +5999,19 @@ function chatAgent(options) {
5405
5999
  capturedPartialResponse = capturedResponseMessage;
5406
6000
  turnCompleteEvent.responseMessage = capturedResponseMessage;
5407
6001
  turnCompleteEvent.uiMessages = accumulatedUIMessages;
6002
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6003
+ }
6004
+ else if (responseWasSkipped) {
6005
+ capturedResponseMessage = {
6006
+ ...capturedResponseMessage,
6007
+ parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
6008
+ };
6009
+ accumulatedUIMessages.push(capturedResponseMessage);
6010
+ turnNewUIMessages.push(capturedResponseMessage);
6011
+ capturedPartialResponse = capturedResponseMessage;
6012
+ turnCompleteEvent.responseMessage = capturedResponseMessage;
6013
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
6014
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5408
6015
  }
5409
6016
  locals_js_1.locals.set(chatResponsePartsKey, []);
5410
6017
  }
@@ -5423,6 +6030,8 @@ function chatAgent(options) {
5423
6030
  locals_js_1.locals.set(chatOverrideMessagesKey, undefined);
5424
6031
  accumulatedUIMessages = [...turnCompleteOverride];
5425
6032
  accumulatedMessages = await toModelMessages(turnCompleteOverride);
6033
+ laneCompacted = false;
6034
+ laneInjections = [];
5426
6035
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5427
6036
  }
5428
6037
  }, {
@@ -5476,13 +6085,18 @@ function chatAgent(options) {
5476
6085
  if (!hydrateMessages) {
5477
6086
  try {
5478
6087
  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(),
6088
+ lastSnapshotOutEventId =
6089
+ turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6090
+ await saveTranscript({
6091
+ reason: "turn-complete",
5483
6092
  messages: accumulatedUIMessages,
5484
- lastOutEventId: turnCompleteResult?.lastEventId,
5485
- lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6093
+ turn,
6094
+ trigger: storageTrigger(currentWirePayload.trigger),
6095
+ clientData,
6096
+ lastOutEventId: lastSnapshotOutEventId,
6097
+ nonFinalIds: wasStopped && capturedResponseMessage?.id
6098
+ ? new Set([capturedResponseMessage.id])
6099
+ : undefined,
5486
6100
  });
5487
6101
  }, {
5488
6102
  attributes: {
@@ -5515,10 +6129,17 @@ function chatAgent(options) {
5515
6129
  currentWirePayload = bootInjectedQueue.shift();
5516
6130
  return "continue";
5517
6131
  }
5518
- // chat.requestUpgrade() was called — exit the loop so the
5519
- // transport triggers a new run on the latest version.
6132
+ // chat.requestUpgrade() was called — exit the loop; the handover
6133
+ // has already triggered a new run on the latest version.
5520
6134
  // chat.endRun() — same exit, no upgrade semantics.
6135
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6136
+ await performChatClose();
6137
+ return "exit";
6138
+ }
5521
6139
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
6140
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
6141
+ await persistUpgradeHandoff();
6142
+ }
5522
6143
  return "exit";
5523
6144
  }
5524
6145
  // Wait for the next message — stay idle briefly, then suspend
@@ -5620,11 +6241,17 @@ function chatAgent(options) {
5620
6241
  let errorTurnCompleteResult;
5621
6242
  try {
5622
6243
  await withChatWriter(async (writer) => {
5623
- const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
6244
+ const errorText = turnError instanceof Error && turnError.message
6245
+ ? turnError.message
6246
+ : "An unexpected error occurred";
5624
6247
  writer.write({ type: "error", errorText });
5625
6248
  });
5626
6249
  // Signal turn complete so the client knows this turn is done
5627
6250
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6251
+ // A later action's snapshot reuses this cursor, so it has to move
6252
+ // here too or that snapshot resumes from before the failed turn.
6253
+ lastSnapshotOutEventId =
6254
+ errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
5628
6255
  }
5629
6256
  catch {
5630
6257
  // Best-effort — if stream write fails, let the run continue anyway
@@ -5669,15 +6296,47 @@ function chatAgent(options) {
5669
6296
  : partialIdx === -1
5670
6297
  ? [...erroredUIMessages, partialResponse]
5671
6298
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
5672
- let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5673
- if (includePartial) {
5674
- erroredNewUIMessages.push(partialResponse);
5675
- }
6299
+ /**
6300
+ * Seeded from the per-turn list, not just the wire message and the
6301
+ * partial, so a steering message the drain consumed is reported too.
6302
+ * An app persisting from `newUIMessages` would otherwise lose the
6303
+ * instruction whenever the turn it steered went on to fail.
6304
+ */
6305
+ const buildErroredNew = () => {
6306
+ const out = [];
6307
+ const addUnique = (m) => {
6308
+ if (m && !out.some((existing) => existing.id === m.id))
6309
+ out.push(m);
6310
+ };
6311
+ addUnique(erroredWireMessage);
6312
+ for (const m of (locals_js_1.locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6313
+ addUnique(m);
6314
+ }
6315
+ if (includePartial)
6316
+ addUnique(partialResponse);
6317
+ return out;
6318
+ };
6319
+ let erroredNewUIMessages = buildErroredNew();
5676
6320
  let erroredNewModelMessages = [];
6321
+ const reconciledSteer = reconcilePendingSteer();
6322
+ const backgroundTailThisTurn = reconcilePendingBackground();
5677
6323
  if (!responseCommitted) {
5678
6324
  try {
5679
6325
  if (erroredNewUIMessages.length > 0) {
5680
- erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6326
+ /**
6327
+ * Built in order from the recorded forms rather than by
6328
+ * converting the UI list, so a steer appears in the delta as
6329
+ * the model received it (what `prepare` produced), matching the
6330
+ * lane. The wire message and partial are converted as before.
6331
+ */
6332
+ const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6333
+ for (const m of erroredNewUIMessages) {
6334
+ const recorded = steerModelById.get(m.id);
6335
+ if (recorded)
6336
+ erroredNewModelMessages.push(...recorded);
6337
+ else
6338
+ erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6339
+ }
5681
6340
  }
5682
6341
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
5683
6342
  if (partialIdx === -1) {
@@ -5685,7 +6344,14 @@ function chatAgent(options) {
5685
6344
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
5686
6345
  }
5687
6346
  else {
5688
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6347
+ const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
6348
+ backgroundTailThisTurn);
6349
+ if (!ok) {
6350
+ v3_1.logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6351
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6352
+ laneCompacted = false;
6353
+ laneInjections = [];
6354
+ }
5689
6355
  }
5690
6356
  accumulatedUIMessages = erroredUIMessagesWithPartial;
5691
6357
  locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -5694,7 +6360,7 @@ function chatAgent(options) {
5694
6360
  catch {
5695
6361
  erroredNewModelMessages = [];
5696
6362
  erroredUIMessagesWithPartial = erroredUIMessages;
5697
- erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6363
+ erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5698
6364
  }
5699
6365
  }
5700
6366
  if (onTurnComplete) {
@@ -5748,13 +6414,14 @@ function chatAgent(options) {
5748
6414
  // neither the snapshot nor the replayable `.in` tail.
5749
6415
  if (!hydrateMessages) {
5750
6416
  try {
5751
- const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5752
- await writeChatSnapshot(sessionIdForSnapshot, {
5753
- version: 1,
5754
- savedAt: Date.now(),
6417
+ await saveTranscript({
6418
+ reason: "turn-error",
5755
6419
  messages: erroredUIMessagesWithPartial,
5756
- lastOutEventId: errorTurnCompleteResult?.lastEventId,
5757
- lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
6420
+ turn,
6421
+ trigger: storageTrigger(currentWirePayload.trigger),
6422
+ clientData: turnClientData,
6423
+ lastOutEventId: lastSnapshotOutEventId,
6424
+ nonFinalIds: includePartial && partialResponse ? new Set([partialResponse.id]) : undefined,
5758
6425
  });
5759
6426
  }
5760
6427
  catch (error) {
@@ -5764,8 +6431,15 @@ function chatAgent(options) {
5764
6431
  });
5765
6432
  }
5766
6433
  }
6434
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6435
+ await performChatClose();
6436
+ return;
6437
+ }
5767
6438
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
5768
6439
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
6440
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
6441
+ await persistUpgradeHandoff();
6442
+ }
5769
6443
  return;
5770
6444
  }
5771
6445
  // Drain remaining recovered turns before idling — a thrown
@@ -5788,6 +6462,12 @@ function chatAgent(options) {
5788
6462
  return; // Timed out — end run gracefully
5789
6463
  }
5790
6464
  currentWirePayload = next.output;
6465
+ // Same close check the success path makes. Without it a close
6466
+ // record that lands after a failed turn is consumed as if it were
6467
+ // a turn payload, and the loop runs on against a closed session.
6468
+ if (currentWirePayload.trigger === "close") {
6469
+ return;
6470
+ }
5791
6471
  // Continue to next iteration of the for loop
5792
6472
  }
5793
6473
  finally {
@@ -5796,6 +6476,11 @@ function chatAgent(options) {
5796
6476
  }
5797
6477
  }
5798
6478
  finally {
6479
+ // Safety net for a close requested on a path that exits without
6480
+ // reaching one of the loop's close checks (a turn timeout, an OOM
6481
+ // re-throw). `performChatClose` is idempotent, so the ordinary path
6482
+ // having already run it costs nothing here.
6483
+ await performChatClose();
5799
6484
  // `stopSub` is registered post-preload so the close-during-preload
5800
6485
  // early-return path may exit before it ever attached. Guard the
5801
6486
  // cleanup so a missing subscription doesn't throw.
@@ -6133,15 +6818,22 @@ function isStopped() {
6133
6818
  // Version upgrade
6134
6819
  // ---------------------------------------------------------------------------
6135
6820
  /**
6136
- * Request that the current run exits so the next message starts on the latest
6137
- * deployed version (via the standard continuation mechanism).
6821
+ * Hand the conversation over to another deployment.
6822
+ *
6823
+ * The handover happens immediately and server-side: a successor run is created
6824
+ * and picks the conversation up from `session.in`. The transport keeps reading
6825
+ * the same session output, so no client action is needed and nothing waits for
6826
+ * the next message.
6827
+ *
6828
+ * Without a target the session's pin is cleared, so the successor lands on the
6829
+ * latest deployed version; with `externalDeploymentId` the session is re-pinned
6830
+ * to that deployment.
6138
6831
  *
6139
6832
  * 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.
6833
+ * entirely and the successor answers the message that opened the turn.
6142
6834
  *
6143
6835
  * 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.
6836
+ * normally and the handover happens afterward.
6145
6837
  *
6146
6838
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6147
6839
  * or inside `chat.defer()`.
@@ -6161,8 +6853,37 @@ function isStopped() {
6161
6853
  * });
6162
6854
  * ```
6163
6855
  */
6164
- function requestUpgrade() {
6856
+ function requestUpgrade(options) {
6165
6857
  locals_js_1.locals.set(chatUpgradeRequestedKey, true);
6858
+ // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
6859
+ const target = options?.externalDeploymentId?.trim();
6860
+ if (target)
6861
+ locals_js_1.locals.set(chatUpgradeExternalDeploymentIdKey, target);
6862
+ }
6863
+ /** @internal Requests a handoff when the session's pin no longer names this deployment. */
6864
+ async function followSessionPin(chatId, policy) {
6865
+ if (!chatId) {
6866
+ return;
6867
+ }
6868
+ const deployedExternalId = locals_js_1.locals.get(chatAgentRunContextKey)?.deployment?.externalId;
6869
+ if (policy !== "hold" && !deployedExternalId) {
6870
+ v3_1.logger.debug("chat.versionSkew: cannot follow the session pin", {
6871
+ chatId,
6872
+ reason: "the run context carries no deployment.externalId",
6873
+ });
6874
+ }
6875
+ const target = await (0, chatVersionSkew_js_1.resolvePinToFollow)({
6876
+ policy,
6877
+ deployedExternalId,
6878
+ upgradeAlreadyRequested: locals_js_1.locals.get(chatUpgradeRequestedKey) === true,
6879
+ readPin: async () => (await sessions_js_1.sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
6880
+ .triggerConfig,
6881
+ });
6882
+ if (!target) {
6883
+ return;
6884
+ }
6885
+ v3_1.logger.info("chat.versionSkew: following the session pin", { chatId, target });
6886
+ requestUpgrade({ externalDeploymentId: target });
6166
6887
  }
6167
6888
  /**
6168
6889
  * Hand off the current custom agent Session to a fresh run.
@@ -6201,20 +6922,31 @@ async function endAndContinue() {
6201
6922
  if ((locals_js_1.locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6202
6923
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6203
6924
  }
6204
- await performEndAndContinue();
6925
+ await performEndAndContinue({ reason: "continuation" });
6205
6926
  }
6206
6927
  /** @internal Shared server handoff used by managed and custom agent loops. */
6207
- async function performEndAndContinue() {
6928
+ async function performEndAndContinue(options) {
6208
6929
  const chatId = locals_js_1.locals.get(chatExternalIdKey);
6209
6930
  const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
6210
6931
  if (!chatId || !callingRunId) {
6211
6932
  throw new Error("Cannot end and continue without an active chat agent run");
6212
6933
  }
6934
+ const externalDeploymentId = options.externalDeploymentId;
6213
6935
  const apiClient = v3_1.apiClientManager.clientOrThrow();
6214
- await apiClient.endAndContinueSession(chatId, {
6936
+ const result = await apiClient.endAndContinueSession(chatId, {
6215
6937
  callingRunId,
6216
- reason: "upgrade",
6938
+ reason: options.reason,
6939
+ ...(externalDeploymentId ? { externalDeploymentId } : {}),
6217
6940
  });
6941
+ if (result?.pendingVersion !== true) {
6942
+ return;
6943
+ }
6944
+ // The successor parked. Say so on `.out` while this run still can — the transport's
6945
+ // subscription survives the swap, so the client learns without waiting for its next send.
6946
+ const [error] = await (0, v3_1.tryCatch)(getChatSession().out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
6947
+ if (error) {
6948
+ v3_1.logger.warn("could not signal a parked handoff", { chatId, error });
6949
+ }
6218
6950
  }
6219
6951
  /**
6220
6952
  * Exit the run after the current turn completes, without waiting for the
@@ -6245,6 +6977,124 @@ async function performEndAndContinue() {
6245
6977
  function endRun() {
6246
6978
  locals_js_1.locals.set(chatEndRunRequestedKey, true);
6247
6979
  }
6980
+ /**
6981
+ * End the whole conversation, permanently. The session row is closed, further
6982
+ * appends are refused, and the run exits without scheduling a continuation.
6983
+ *
6984
+ * This is the session-level stop. {@link endRun} ends the current run and lets
6985
+ * the next message start a fresh one; `chat.close()` ends the session itself,
6986
+ * so there is no next message. Use it for a budget cap, a completed goal,
6987
+ * abuse detection, or a user signing out.
6988
+ *
6989
+ * In a `chat.agent`, call it from `run()`, `prepareStep`, or
6990
+ * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
6991
+ * `streamText` the same way the stop signal does, so the partial response is
6992
+ * still captured and streamed. The turn then completes normally, a terminal
6993
+ * `session-closed` record carrying `reason` is written to the response stream,
6994
+ * and the loop exits.
6995
+ *
6996
+ * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
6997
+ * fields but still runs while the stream is open, so the closed state rides
6998
+ * out on the turn's final record. `onTurnComplete` runs after that record, so
6999
+ * a close decided there does not reach a reader that has already finished the
7000
+ * turn, and the user only finds out when their next message is refused.
7001
+ *
7002
+ * In a `chat.customAgent`, call it anywhere in your own loop. The close is
7003
+ * performed when `run()` returns, so it lands whether you break out of a
7004
+ * `chat.createSession` loop, return early, or hand-roll the loop entirely.
7005
+ *
7006
+ * Closing is one-way: a closed session cannot be reopened. Its transcript
7007
+ * stays readable.
7008
+ *
7009
+ * @example
7010
+ * ```ts
7011
+ * chat.agent({
7012
+ * id: "budgeted-agent",
7013
+ * onBeforeTurnComplete: async ({ usage }) => {
7014
+ * if (await overBudget(usage)) {
7015
+ * chat.close({ reason: "Monthly budget reached" });
7016
+ * }
7017
+ * },
7018
+ * });
7019
+ * ```
7020
+ */
7021
+ function close(options) {
7022
+ if (!locals_js_1.locals.get(chatExternalIdKey)) {
7023
+ throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
7024
+ }
7025
+ // Bound the reason once, here. It goes out on S2 record headers as well as
7026
+ // the close API, and an oversized value would fail the turn-complete write
7027
+ // that carries the turn boundary, costing the client far more than the
7028
+ // reason text.
7029
+ // Trailing high surrogate: the cut landed between the two halves of an
7030
+ // astral character, and encoding the orphan to UTF-8 for a record header
7031
+ // yields a replacement character. Drop it rather than ship mojibake.
7032
+ const reason = options?.reason
7033
+ ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
7034
+ .replace(/[\uD800-\uDBFF]$/, "");
7035
+ locals_js_1.locals.set(chatCloseRequestedKey, reason ? { reason } : {});
7036
+ // Mid-step call: unblock the in-flight streamText exactly like the stop
7037
+ // signal, so the turn can reach its turn boundary instead of running the
7038
+ // model out to completion after the decision to close has been made.
7039
+ locals_js_1.locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
7040
+ }
7041
+ /**
7042
+ * @internal Terminal close sequence, run once at whichever exit site observes
7043
+ * the close request. Writes the standalone `session-closed` record, then closes
7044
+ * the session row.
7045
+ *
7046
+ * The record lands after the turn's `turn-complete`, so a client reading that
7047
+ * turn's stream has already terminated on it and will not see this one. It is
7048
+ * there for a reconnect and for replay. What a live client reads is the
7049
+ * `session-closed` header stamped onto `turn-complete` itself by
7050
+ * `writeTurnCompleteChunk`, which fires whenever the close was decided before
7051
+ * the turn ended. A close decided from `onTurnComplete` is past that point, so
7052
+ * the client learns from the 409 on its next send.
7053
+ */
7054
+ async function performChatClose() {
7055
+ const request = locals_js_1.locals.get(chatCloseRequestedKey);
7056
+ if (!request || locals_js_1.locals.get(chatClosePerformedKey))
7057
+ return;
7058
+ const reason = request.reason;
7059
+ // Two flags, not one. The record is a client-visible event and must not be
7060
+ // written twice, but the row close is the part that actually ends the
7061
+ // conversation: flagging it as done before it succeeds would let a transient
7062
+ // failure leave the session open with no later call willing to retry.
7063
+ if (!locals_js_1.locals.get(chatCloseRecordWrittenKey)) {
7064
+ locals_js_1.locals.set(chatCloseRecordWrittenKey, true);
7065
+ try {
7066
+ const session = getChatSession();
7067
+ await session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[v3_1.SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
7068
+ }
7069
+ catch (error) {
7070
+ v3_1.logger.warn("chat.close: failed to write the session-closed record", {
7071
+ error: error instanceof Error ? error.message : String(error),
7072
+ });
7073
+ }
7074
+ }
7075
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
7076
+ if (!chatId)
7077
+ return;
7078
+ try {
7079
+ await sessions_js_1.sessions.close(chatId, {
7080
+ ...(reason ? { reason } : {}),
7081
+ ...(locals_js_1.locals.get(chatAgentRunContextKey)?.run.id
7082
+ ? { callingRunId: locals_js_1.locals.get(chatAgentRunContextKey).run.id }
7083
+ : {}),
7084
+ });
7085
+ locals_js_1.locals.set(chatClosePerformedKey, true);
7086
+ }
7087
+ catch (error) {
7088
+ // Deliberately NOT flagged as performed: the close API is idempotent, so a
7089
+ // later exit site on this run gets to retry it. Losing every retry to a
7090
+ // transient failure would leave the row open and the conversation alive.
7091
+ // Non-fatal either way — the run still exits.
7092
+ v3_1.logger.error("chat.close: failed to close the session", {
7093
+ chatId,
7094
+ error: error instanceof Error ? error.message : String(error),
7095
+ });
7096
+ }
7097
+ }
6248
7098
  // ---------------------------------------------------------------------------
6249
7099
  // Per-turn deferred work
6250
7100
  // ---------------------------------------------------------------------------
@@ -6306,9 +7156,18 @@ function chatDefer(promiseOrFn) {
6306
7156
  * ```
6307
7157
  */
6308
7158
  function injectBackgroundContext(messages) {
6309
- const queue = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
6310
- queue.push(...messages);
6311
- locals_js_1.locals.set(chatBackgroundQueueKey, queue);
7159
+ const systemBlocks = messages.filter((message) => message.role === "system");
7160
+ const conversational = messages.filter((message) => message.role !== "system");
7161
+ if (systemBlocks.length > 0) {
7162
+ const instructions = locals_js_1.locals.get(chatInjectedInstructionsKey) ?? [];
7163
+ instructions.push(...systemBlocks);
7164
+ locals_js_1.locals.set(chatInjectedInstructionsKey, instructions);
7165
+ }
7166
+ if (conversational.length > 0) {
7167
+ const queue = locals_js_1.locals.get(chatBackgroundQueueKey) ?? [];
7168
+ queue.push(...conversational);
7169
+ locals_js_1.locals.set(chatBackgroundQueueKey, queue);
7170
+ }
6312
7171
  }
6313
7172
  // ---------------------------------------------------------------------------
6314
7173
  // Aborted message cleanup
@@ -6721,10 +7580,12 @@ class ChatMessageAccumulator {
6721
7580
  // a duplicate, mirroring the chat.agent accumulator.
6722
7581
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
6723
7582
  if (existingIdx !== -1) {
7583
+ const previous = this.uiMessages[existingIdx];
6724
7584
  this.uiMessages[existingIdx] = response;
6725
7585
  try {
6726
- // Reconvert all model messages since we replaced rather than appended.
6727
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7586
+ if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7587
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7588
+ }
6728
7589
  }
6729
7590
  catch {
6730
7591
  // Conversion failed — leave the existing model messages in place
@@ -6760,6 +7621,28 @@ class ChatMessageAccumulator {
6760
7621
  const modelMsgs = await toModelMessages([message]);
6761
7622
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
6762
7623
  }
7624
+ /**
7625
+ * Record the messages a steering drain consumed.
7626
+ *
7627
+ * The drain only puts them in this step's prompt, so without this they
7628
+ * shape one answer and then exist in neither lane: not in `uiMessages`,
7629
+ * which is what an app persists from, and not in `modelMessages`, which is
7630
+ * what every later turn sends.
7631
+ *
7632
+ * Both lanes are appended to. The model lane is never reconverted from the
7633
+ * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7634
+ * the UI lane whole: a reconversion would restore everything the summary
7635
+ * replaced.
7636
+ */
7637
+ async absorbSteering(claimed, injected) {
7638
+ const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7639
+ if (fresh.length === 0)
7640
+ return;
7641
+ this.uiMessages.push(...fresh);
7642
+ // Record what the model received. Only when the whole batch is new is
7643
+ // `injected` known to describe exactly these messages.
7644
+ this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7645
+ }
6763
7646
  /**
6764
7647
  * Get and clear unconsumed steering messages.
6765
7648
  */
@@ -6792,7 +7675,8 @@ class ChatMessageAccumulator {
6792
7675
  }
6793
7676
  // 2. Pending message injection
6794
7677
  if (pm && queue.length > 0) {
6795
- const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7678
+ const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7679
+ await this.absorbSteering(claimed, injected);
6796
7680
  if (injected.length > 0) {
6797
7681
  resultMessages = [...(resultMessages ?? messages), ...injected];
6798
7682
  }
@@ -6981,7 +7865,7 @@ function trackActiveChatSessionIterator(iterator) {
6981
7865
  * ```
6982
7866
  */
6983
7867
  function createChatSession(payload, options) {
6984
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
7868
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6985
7869
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
6986
7870
  return {
6987
7871
  [Symbol.asyncIterator]() {
@@ -7070,8 +7954,16 @@ function createChatSession(payload, options) {
7070
7954
  * without suspending.
7071
7955
  */
7072
7956
  if (turn > 0) {
7957
+ if (locals_js_1.locals.get(chatCloseRequestedKey)) {
7958
+ await performChatClose();
7959
+ stop.cleanup();
7960
+ return { done: true, value: undefined };
7961
+ }
7073
7962
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7074
7963
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
7964
+ if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
7965
+ await persistUpgradeHandoff();
7966
+ }
7075
7967
  stop.cleanup();
7076
7968
  return { done: true, value: undefined };
7077
7969
  }
@@ -7174,6 +8066,7 @@ function createChatSession(payload, options) {
7174
8066
  }
7175
8067
  accumulator.applyHandover(pendingHandoverSignal);
7176
8068
  }
8069
+ await followSessionPin(currentPayload.chatId, sessionVersionSkew);
7177
8070
  // chat.requestUpgrade() called before this turn — signal transport and exit
7178
8071
  if (locals_js_1.locals.get(chatUpgradeRequestedKey)) {
7179
8072
  await writeUpgradeRequiredChunk();
@@ -7390,7 +8283,8 @@ function createChatSession(payload, options) {
7390
8283
  }
7391
8284
  }
7392
8285
  if (sessionPendingMessages) {
7393
- const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8286
+ const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8287
+ await accumulator.absorbSteering(claimed, injected);
7394
8288
  if (injected.length > 0) {
7395
8289
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
7396
8290
  }
@@ -7404,6 +8298,11 @@ function createChatSession(payload, options) {
7404
8298
  async return() {
7405
8299
  activeMsgSub?.off();
7406
8300
  activeMsgSub = undefined;
8301
+ // Reached when the consumer leaves the `for await` early (`break`,
8302
+ // `return`, a throw). A `chat.close()` from the loop body would
8303
+ // otherwise be dropped: the exit that performs it lives in `next()`,
8304
+ // and `next()` is never called again.
8305
+ await performChatClose();
7407
8306
  // `stop` only exists once next() has booted the iterator.
7408
8307
  stop?.cleanup();
7409
8308
  return { done: true, value: undefined };
@@ -7660,6 +8559,11 @@ function createChatStartSessionAction(taskId, options) {
7660
8559
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
7661
8560
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
7662
8561
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8562
+ // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8563
+ // action default, which neither truthiness nor `??` would allow.
8564
+ const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8565
+ ? params.triggerConfig.externalDeploymentId
8566
+ : options?.triggerConfig?.externalDeploymentId;
7663
8567
  const triggerConfig = {
7664
8568
  basePayload: {
7665
8569
  messages: [],
@@ -7686,6 +8590,10 @@ function createChatStartSessionAction(taskId, options) {
7686
8590
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
7687
8591
  }
7688
8592
  : {}),
8593
+ ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8594
+ ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8595
+ : {}),
8596
+ ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
7689
8597
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
7690
8598
  };
7691
8599
  const startBody = {
@@ -7730,6 +8638,7 @@ function createChatStartSessionAction(taskId, options) {
7730
8638
  publicAccessToken,
7731
8639
  runId: created.runId,
7732
8640
  sessionId: created.id,
8641
+ ...(created.pendingVersion ? { pendingVersion: true } : {}),
7733
8642
  };
7734
8643
  };
7735
8644
  }
@@ -7763,7 +8672,8 @@ async function callSessionsCreateWithOverride(args) {
7763
8672
  const init = {
7764
8673
  method: "POST",
7765
8674
  headers: overrideRequestHeaders(accessToken),
7766
- body: JSON.stringify(args.body),
8675
+ // This path bypasses `sessions.start`, so it resolves the pin itself.
8676
+ body: JSON.stringify((0, externalDeploymentId_js_1.withResolvedExternalDeploymentId)(args.body)),
7767
8677
  };
7768
8678
  const response = args.fetchOverride
7769
8679
  ? await args.fetchOverride(url, init, ctx)
@@ -7828,6 +8738,41 @@ async function mintPublicTokenWithOverride(args) {
7828
8738
  expirationTime: args.expirationTime,
7829
8739
  });
7830
8740
  }
8741
+ /**
8742
+ * Creates a server-side helper that reads a conversation from a transcript
8743
+ * storage, for rendering history before the chat connects. Works the same
8744
+ * for every storage, the platform default included, so the browser never
8745
+ * reads a store directly and the secret key stays on the server.
8746
+ *
8747
+ * Wrap it in a Next.js server action (or any server-side handler), scope it
8748
+ * to the authenticated user through `clientData`, and pass the result to
8749
+ * `useLoadTranscript` in the browser.
8750
+ *
8751
+ * @example
8752
+ * ```ts
8753
+ * // actions.ts
8754
+ * "use server";
8755
+ * import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
8756
+ *
8757
+ * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
8758
+ * ```
8759
+ */
8760
+ function createChatLoadTranscriptAction(storage, options) {
8761
+ return async (params) => {
8762
+ if (!params.chatId) {
8763
+ throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
8764
+ }
8765
+ if (options?.apiClient) {
8766
+ const { apiClient, ...rest } = options;
8767
+ return v3_1.apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
8768
+ }
8769
+ const limit = params.limit ?? options?.limit;
8770
+ return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
8771
+ ...(limit !== undefined ? { limit } : {}),
8772
+ ...(params.before !== undefined ? { before: params.before } : {}),
8773
+ });
8774
+ };
8775
+ }
7831
8776
  exports.chat = {
7832
8777
  /** Create a chat agent. See {@link chatAgent}. */
7833
8778
  agent: chatAgent,
@@ -7839,8 +8784,12 @@ exports.chat = {
7839
8784
  withClientData,
7840
8785
  /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
7841
8786
  createStartSessionAction: createChatStartSessionAction,
8787
+ /** Returns a server-side helper that reads a conversation from a transcript storage. */
8788
+ createLoadTranscriptAction: createChatLoadTranscriptAction,
7842
8789
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
7843
8790
  pipe: pipeChat,
8791
+ /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
8792
+ turn: chatTurn,
7844
8793
  /** Create a per-run typed local. See {@link chatLocal}. */
7845
8794
  local: chatLocal,
7846
8795
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -7861,6 +8810,8 @@ exports.chat = {
7861
8810
  endAndContinue,
7862
8811
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7863
8812
  endRun,
8813
+ /** End the conversation permanently: close the session and exit the run. See {@link close}. */
8814
+ close,
7864
8815
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
7865
8816
  cleanupAbortedParts,
7866
8817
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
@@ -8008,6 +8959,16 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8008
8959
  if (consumedCursor !== undefined) {
8009
8960
  extraHeaders.push([v3_1.SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8010
8961
  }
8962
+ // A close decided before this turn ended rides out on turn-complete. Readers
8963
+ // terminate their stream on turn-complete, so a standalone record written
8964
+ // after it only reaches a reconnect — this header is what a live client sees.
8965
+ const pendingClose = locals_js_1.locals.get(chatCloseRequestedKey);
8966
+ if (pendingClose) {
8967
+ extraHeaders.push([v3_1.SESSION_CLOSED_HEADER, "true"]);
8968
+ if (pendingClose.reason) {
8969
+ extraHeaders.push([v3_1.SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
8970
+ }
8971
+ }
8011
8972
  const result = await session.out.writeControl(v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8012
8973
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8013
8974
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8065,12 +9026,47 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8065
9026
  *
8066
9027
  * @internal
8067
9028
  */
9029
+ /**
9030
+ * Persists an upgrade requested after the turn has already run.
9031
+ *
9032
+ * The pre-turn sites reach {@link performEndAndContinue} through
9033
+ * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
9034
+ * session's stored `externalDeploymentId`. The post-turn exits had no such path,
9035
+ * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
9036
+ * and every continuation re-pinned to the deployment the agent asked to leave.
9037
+ *
9038
+ * No `upgrade-required` chunk is written here: the turn already produced its
9039
+ * answer, so there is nothing for a client to be told about.
9040
+ */
9041
+ async function persistUpgradeHandoff() {
9042
+ const chatId = locals_js_1.locals.get(chatExternalIdKey);
9043
+ const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
9044
+ if (!chatId || !callingRunId) {
9045
+ return;
9046
+ }
9047
+ try {
9048
+ await performEndAndContinue({
9049
+ reason: "upgrade",
9050
+ externalDeploymentId: locals_js_1.locals.get(chatUpgradeExternalDeploymentIdKey),
9051
+ });
9052
+ }
9053
+ catch (error) {
9054
+ v3_1.logger.warn("upgrade handoff failed; session keeps its current version pin", {
9055
+ chatId,
9056
+ callingRunId,
9057
+ error,
9058
+ });
9059
+ }
9060
+ }
8068
9061
  async function writeUpgradeRequiredChunk() {
8069
9062
  const chatId = locals_js_1.locals.get(chatExternalIdKey);
8070
9063
  const callingRunId = locals_js_1.locals.get(chatAgentRunContextKey)?.run.id;
8071
9064
  if (chatId && callingRunId) {
8072
9065
  try {
8073
- await performEndAndContinue();
9066
+ await performEndAndContinue({
9067
+ reason: "upgrade",
9068
+ externalDeploymentId: locals_js_1.locals.get(chatUpgradeExternalDeploymentIdKey),
9069
+ });
8074
9070
  }
8075
9071
  catch (error) {
8076
9072
  // Non-fatal: the next `.in/append` re-triggers via the probe.