@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
package/dist/esm/v3/ai.js CHANGED
@@ -1,8 +1,18 @@
1
- import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
1
+ import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, tryCatch, } from "@trigger.dev/core/v3";
2
2
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
3
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
4
  import { trace } from "@opentelemetry/api";
5
- import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, zodSchema, } from "../imports/ai-runtime.js";
5
+ import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, streamText as aiStreamText, zodSchema, } from "../imports/ai-runtime.js";
6
+ import { createTranscriptShadow, defaultStorage, diffTranscript, parseTranscriptRuntimeState, restoreModelLane, } from "./transcriptStorage.js";
7
+ let transcriptStorageOverride;
8
+ /**
9
+ * Test-only override for the storage `chat.agent` persists through, so a
10
+ * test can capture the exact changesets the runtime produces.
11
+ * @internal
12
+ */
13
+ export function __setTranscriptStorageForTests(storage) {
14
+ transcriptStorageOverride = storage;
15
+ }
6
16
  import { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage, chatRunTags, } from "./ai-shared.js";
7
17
  import { auth } from "./auth.js";
8
18
  import { locals } from "./locals.js";
@@ -17,6 +27,8 @@ import { metadata } from "./metadata.js";
17
27
  // pulled in transitively here never reach a client chunk.
18
28
  import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js";
19
29
  import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
30
+ import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
31
+ import { resolvePinToFollow } from "./chatVersionSkew.js";
20
32
  import { sessions, } from "./sessions.js";
21
33
  import { createTask } from "./shared.js";
22
34
  import { markChatAgentRunForStreamsWarning } from "./streams.js";
@@ -103,182 +115,8 @@ async function findLatestSessionInCursor(chatId) {
103
115
  }
104
116
  return latestCursor;
105
117
  }
106
- let readChatSnapshotImpl;
107
- export function __setReadChatSnapshotImplForTests(impl) {
108
- readChatSnapshotImpl = impl;
109
- }
110
- let writeChatSnapshotImpl;
111
- export function __setWriteChatSnapshotImplForTests(impl) {
112
- writeChatSnapshotImpl = impl;
113
- }
114
- /**
115
- * Read the persisted snapshot for a session. Returns `undefined` on:
116
- * - missing object (404 from the presigned GET — fresh session, never
117
- * persisted)
118
- * - presign failure (network/auth issue)
119
- * - malformed JSON
120
- * - version mismatch (forward-compat — older runtimes ignore newer blobs)
121
- *
122
- * Always swallows errors via `logger.warn`. The agent boot loop must stay
123
- * available even if S3 hiccups; the worst case is replaying more of
124
- * `session.out` than strictly necessary.
125
- * @internal
126
- */
127
- async function readChatSnapshot(sessionId) {
128
- if (readChatSnapshotImpl) {
129
- return (await readChatSnapshotImpl(sessionId)) ?? undefined;
130
- }
131
- const apiClient = apiClientManager.clientOrThrow();
132
- let presignedUrl;
133
- try {
134
- const resp = await apiClient.getChatSnapshotUrl(sessionId);
135
- presignedUrl = resp.presignedUrl;
136
- }
137
- catch (error) {
138
- logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", {
139
- error: error instanceof Error ? error.message : String(error),
140
- sessionId,
141
- });
142
- return undefined;
143
- }
144
- let response;
145
- try {
146
- response = await fetch(presignedUrl, { method: "GET" });
147
- }
148
- catch (error) {
149
- logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", {
150
- error: error instanceof Error ? error.message : String(error),
151
- sessionId,
152
- });
153
- return undefined;
154
- }
155
- if (response.status === 404) {
156
- // First-ever boot for this session — no snapshot yet. Caller falls
157
- // through to replay-only.
158
- return undefined;
159
- }
160
- if (!response.ok) {
161
- logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", {
162
- status: response.status,
163
- sessionId,
164
- });
165
- return undefined;
166
- }
167
- let parsed;
168
- try {
169
- parsed = await response.json();
170
- }
171
- catch (error) {
172
- logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", {
173
- error: error instanceof Error ? error.message : String(error),
174
- sessionId,
175
- });
176
- return undefined;
177
- }
178
- if (!parsed || typeof parsed !== "object")
179
- return undefined;
180
- const candidate = parsed;
181
- if (candidate.version !== 1 || !Array.isArray(candidate.messages)) {
182
- logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", {
183
- version: candidate.version,
184
- sessionId,
185
- });
186
- return undefined;
187
- }
188
- return candidate;
189
- }
190
- /**
191
- * Persist the snapshot for a session. Awaited by callers immediately after
192
- * `onTurnComplete` — the agent may suspend right after this point, and
193
- * fire-and-forget promises don't reliably complete on suspend.
194
- *
195
- * Errors are swallowed via `logger.warn`. A failed write means the next
196
- * boot replays slightly more of `session.out` (back to the previous
197
- * snapshot's cursor) instead of failing — the conversation stays
198
- * coherent, only the boot path does marginally more work.
199
- * @internal
200
- */
201
- async function writeChatSnapshot(sessionId, snapshot) {
202
- if (writeChatSnapshotImpl) {
203
- await writeChatSnapshotImpl(sessionId, snapshot);
204
- return;
205
- }
206
- const apiClient = apiClientManager.clientOrThrow();
207
- let presignedUrl;
208
- try {
209
- const resp = await apiClient.createChatSnapshotUploadUrl(sessionId);
210
- presignedUrl = resp.presignedUrl;
211
- }
212
- catch (error) {
213
- logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", {
214
- error: error instanceof Error ? error.message : String(error),
215
- sessionId,
216
- });
217
- return;
218
- }
219
- let response;
220
- try {
221
- response = await fetch(presignedUrl, {
222
- method: "PUT",
223
- headers: { "content-type": "application/json" },
224
- body: JSON.stringify(snapshot),
225
- });
226
- }
227
- catch (error) {
228
- logger.warn("chat.agent: snapshot upload failed; next run will replay further", {
229
- error: error instanceof Error ? error.message : String(error),
230
- sessionId,
231
- });
232
- return;
233
- }
234
- if (!response.ok) {
235
- logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", {
236
- status: response.status,
237
- sessionId,
238
- });
239
- }
240
- }
241
- /**
242
- * Test-only entry point that bypasses `__setReadChatSnapshotImplForTests`
243
- * and reaches the real `apiClient.getPayloadUrl` + `fetch` + JSON-parse path.
244
- * Used by `chat-snapshot.test.ts` to verify 404 / 500 / malformed JSON /
245
- * version-mismatch / network-error behavior end-to-end. Tests mock global
246
- * `fetch` and the api-client config; this wrapper lets them drive the
247
- * production code without the override hook short-circuiting.
248
- *
249
- * Not part of the public API. The `__` prefix and `ForTests` suffix mirror
250
- * the override-hook setters above.
251
- * @internal
252
- */
253
- export async function __readChatSnapshotProductionPathForTests(sessionId) {
254
- const saved = readChatSnapshotImpl;
255
- readChatSnapshotImpl = undefined;
256
- try {
257
- return await readChatSnapshot(sessionId);
258
- }
259
- finally {
260
- readChatSnapshotImpl = saved;
261
- }
262
- }
263
- /**
264
- * Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests`
265
- * and reaches the real `apiClient.createUploadPayloadUrl` + `fetch` PUT
266
- * path. Pairs with `__readChatSnapshotProductionPathForTests` — see that
267
- * function's note for the rationale.
268
- *
269
- * Not part of the public API.
270
- * @internal
271
- */
272
- export async function __writeChatSnapshotProductionPathForTests(sessionId, snapshot) {
273
- const saved = writeChatSnapshotImpl;
274
- writeChatSnapshotImpl = undefined;
275
- try {
276
- await writeChatSnapshot(sessionId, snapshot);
277
- }
278
- finally {
279
- writeChatSnapshotImpl = saved;
280
- }
281
- }
118
+ export { __readChatSnapshotProductionPathForTests, __setReadChatSnapshotImplForTests, __setWriteChatSnapshotImplForTests, __writeChatSnapshotProductionPathForTests, } from "./chatSnapshotIo.js";
119
+ export { defaultStorage, memoryTranscriptStorage, reduceTranscriptChanges, snapshotTranscriptStorage, } from "./transcriptStorage.js";
282
120
  /**
283
121
  * Merge two `UIMessage[]` lists by `id`, with the second list winning on
284
122
  * collision. Used at run boot to combine the snapshot's persisted history
@@ -1027,7 +865,7 @@ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1027
865
  }
1028
866
  }
1029
867
  async function reportChatCustomAgentClientDataError(payload, error, options) {
1030
- const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
868
+ const errorText = error instanceof Error && error.message ? error.message : "An unexpected error occurred";
1031
869
  logger.warn("chat.customAgent: clientData validation failed", {
1032
870
  chatId: payload.chatId,
1033
871
  trigger: payload.trigger,
@@ -1754,6 +1592,13 @@ async function installChatInputRouter(chatId, options) {
1754
1592
  checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1755
1593
  }
1756
1594
  }
1595
+ // A boot that replayed `.in` itself has already answered everything up to
1596
+ // `recoveredThrough`, so the floor has to cover it before the tail opens.
1597
+ if (options?.recoveredThrough !== undefined) {
1598
+ const recovered = options.recoveredThrough;
1599
+ checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
1600
+ checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? checkpoint.resumeFrom, checkpoint.resumeFrom);
1601
+ }
1757
1602
  const router = entry.router;
1758
1603
  router.restore(checkpoint);
1759
1604
  const floor = router.resumeFrom();
@@ -1762,6 +1607,10 @@ async function installChatInputRouter(chatId, options) {
1762
1607
  sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1763
1608
  }
1764
1609
  sessionStreams.onRecord(chatId, "in", (record) => {
1610
+ // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1611
+ // re-deliver below it and a replayable route would re-queue it.
1612
+ if (floor !== undefined && record.seqNum <= floor)
1613
+ return true;
1765
1614
  router.ingest(record);
1766
1615
  return true;
1767
1616
  });
@@ -1933,6 +1782,35 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1933
1782
  * @internal
1934
1783
  */
1935
1784
  const chatBackgroundQueueKey = locals.create("chat.backgroundQueue");
1785
+ /**
1786
+ * Background injections a step-boundary drain handed to the model this turn,
1787
+ * with the transcript message they followed. Reconciled into the model lane
1788
+ * and the persisted injections once the turn's response is in.
1789
+ */
1790
+ const chatPendingBackgroundKey = locals.create("chat.pendingBackground");
1791
+ /**
1792
+ * System-role context injected mid-conversation, held for the instructions lane.
1793
+ *
1794
+ * Kept apart from the message queue because ai@7 rejects a system message inside
1795
+ * `messages` for every provider — `standardizePrompt` throws upstream of any
1796
+ * provider call, and its own advice is to use the instructions option. Instructions
1797
+ * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1798
+ * home: appended as another system block rather than smuggled into the transcript.
1799
+ *
1800
+ * This is also the only way to inject *trusted* context. A message injected as
1801
+ * `user` is untrusted by construction, and a well-aligned model treats it that
1802
+ * way — it will say so, and re-derive the answer from tools instead.
1803
+ */
1804
+ const chatInjectedInstructionsKey = locals.create("chat.injectedInstructions");
1805
+ /**
1806
+ * What a turn already consumed from the instructions lane, so a second
1807
+ * `toStreamTextOptions()` call in the same turn sees the same blocks.
1808
+ *
1809
+ * Consumed blocks are moved here rather than left in the pending lane: leaving
1810
+ * them there means an injection made during the consumed turn sits behind them,
1811
+ * and clearing the lane on the next turn destroys both.
1812
+ */
1813
+ const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsConsumed");
1936
1814
  /**
1937
1815
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1938
1816
  * same worker don't share state.
@@ -2443,12 +2321,31 @@ const chatToolsOptionKey = locals.create("chat.toolsOption");
2443
2321
  const chatResolvedToolsKey = locals.create("chat.resolvedTools");
2444
2322
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2445
2323
  const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested");
2324
+ /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2325
+ const chatUpgradeExternalDeploymentIdKey = locals.create("chat.upgradeExternalDeploymentId");
2446
2326
  /**
2447
2327
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2448
2328
  * turn completes, without any upgrade semantics. Checked at the same
2449
2329
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2450
2330
  */
2451
2331
  const chatEndRunRequestedKey = locals.create("chat.endRunRequested");
2332
+ /**
2333
+ * @internal Set by `chat.close()`. Holds the close request (and its reason)
2334
+ * for the rest of the run: the loop writes the terminal `session-closed`
2335
+ * record, closes the session row, and exits at the same post-turn /
2336
+ * pre-wait sites as `chatEndRunRequestedKey`.
2337
+ */
2338
+ const chatCloseRequestedKey = locals.create("chat.closeRequested");
2339
+ /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2340
+ const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2341
+ /** @internal Set once the session row is closed, so the close happens once. */
2342
+ const chatClosePerformedKey = locals.create("chat.closePerformed");
2343
+ /**
2344
+ * @internal Set once the terminal `.out` record is written. Tracked apart from
2345
+ * {@link chatClosePerformedKey} so a retried close does not emit a second
2346
+ * client-visible event.
2347
+ */
2348
+ const chatCloseRecordWrittenKey = locals.create("chat.closeRecordWritten");
2452
2349
  /** @internal */
2453
2350
  const chatAgentCompactionKey = locals.create("chat.agentCompaction");
2454
2351
  /**
@@ -2465,6 +2362,32 @@ export { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage };
2465
2362
  const chatPendingMessagesKey = locals.create("chat.pendingMessages");
2466
2363
  /** @internal */
2467
2364
  const chatSteeringQueueKey = locals.create("chat.steeringQueue");
2365
+ /**
2366
+ * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2367
+ *
2368
+ * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2369
+ * has to append the messages it injects. Without that, an injected message
2370
+ * reaches the model and the browser but no hook, so an app persisting from
2371
+ * `onTurnComplete` never learns it existed.
2372
+ */
2373
+ const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages");
2374
+ /**
2375
+ * Steering messages a drain consumed that the model accumulator has not been
2376
+ * given yet.
2377
+ *
2378
+ * The two accumulators are maintained separately, and the model one is
2379
+ * normally advanced by appending each turn's delta. A drained message is
2380
+ * appended to the UI one but reaches the model only through the `prepareStep`
2381
+ * return value, which is per-step: without this the model lane never learns
2382
+ * the message exists and every later turn of the run answers without it,
2383
+ * while the browser, the snapshot and `chat.history.*` all still show it.
2384
+ *
2385
+ * Held as the messages rather than a "rebuild me" flag because the model lane
2386
+ * can only be appended to, never reconstructed. Compaction replaces it with a
2387
+ * summary and deliberately leaves the UI lane whole, so reconverting the UI
2388
+ * lane restores every message the summary replaced.
2389
+ */
2390
+ const chatPendingSteerKey = locals.create("chat.pendingSteer");
2468
2391
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2469
2392
  const chatInjectedMessageIdsKey = locals.create("chat.injectedMessageIds");
2470
2393
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2817,20 +2740,32 @@ function chatCompactionStep(options) {
2817
2740
  return result.type === "skipped" ? undefined : result;
2818
2741
  };
2819
2742
  }
2820
- // ---------------------------------------------------------------------------
2821
- // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2822
- // ---------------------------------------------------------------------------
2743
+ const EMPTY_DRAIN = { injected: [], claimed: [] };
2744
+ /**
2745
+ * The model messages to record for one claimed message. Without `prepare`
2746
+ * each entry's own conversion is used. With it, `prepare` returned one list
2747
+ * for the whole batch, so the first claimed message carries all of it and the
2748
+ * rest carry none, which keeps the total exactly what the model received.
2749
+ */
2750
+ function modelFormOf(m, batch, injected) {
2751
+ return batch[0] === m ? injected : [];
2752
+ }
2823
2753
  /**
2824
2754
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2825
2755
  * pending messages. If it returns true, calls `prepareMessages` once to
2826
2756
  * transform the batch, then clears the queue.
2827
- * Returns the model messages to inject (empty if none).
2757
+ * Returns the model messages to inject and the UI messages actually claimed.
2758
+ *
2759
+ * `claimed` is returned rather than only published to locals because each
2760
+ * surface files it somewhere different: `chat.agent` has an accumulator in
2761
+ * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2762
+ * is silently a no-op for any surface that never set the key.
2828
2763
  * @internal
2829
2764
  */
2830
2765
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2831
2766
  const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
2832
2767
  if (!queue || queue.length === 0)
2833
- return [];
2768
+ return EMPTY_DRAIN;
2834
2769
  const ctx = locals.get(chatTurnContextKey);
2835
2770
  const stepNumber = steps.length - 1;
2836
2771
  /**
@@ -2853,7 +2788,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2853
2788
  // Call shouldInject once for the whole batch
2854
2789
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2855
2790
  if (!shouldInject)
2856
- return [];
2791
+ return EMPTY_DRAIN;
2857
2792
  const textOfUIMessage = (m) => (m.parts ?? [])
2858
2793
  .filter((p) => p.type === "text")
2859
2794
  .map((p) => p.text)
@@ -2899,7 +2834,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2899
2834
  queue.splice(at, 1);
2900
2835
  }
2901
2836
  if (claimed.length === 0)
2902
- return [];
2837
+ return EMPTY_DRAIN;
2903
2838
  /**
2904
2839
  * Give the claim back if the transform fails. `prepare` is caller code and
2905
2840
  * can throw; the records have already left the router by this point, so
@@ -2929,6 +2864,37 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2929
2864
  for (const m of claimedUIMessages)
2930
2865
  injectedIds.add(m.id);
2931
2866
  }
2867
+ // Record them as part of the conversation.
2868
+ //
2869
+ // The model has them and the browser has them; without this the
2870
+ // accumulator does not, so they reach neither `uiMessages` nor
2871
+ // `newUIMessages` on `onTurnComplete` and an app that persists from there
2872
+ // silently loses the instruction the answer was shaped by. Appending here
2873
+ // rather than at turn end keeps them in the order they happened: after the
2874
+ // message that started the turn, before the response that answers it.
2875
+ //
2876
+ // De-duplicated by id because a step boundary can drain more than once per
2877
+ // turn, and because a message that failed to inject falls back to becoming
2878
+ // its own turn, where it is accumulated the normal way.
2879
+ const currentUIMessages = locals.get(chatCurrentUIMessagesKey);
2880
+ const turnNew = locals.get(chatTurnNewUIMessagesKey);
2881
+ for (const m of claimedUIMessages) {
2882
+ if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
2883
+ currentUIMessages.push(m);
2884
+ }
2885
+ if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
2886
+ turnNew.push(m);
2887
+ }
2888
+ }
2889
+ if (claimedUIMessages.length > 0 && currentUIMessages) {
2890
+ const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
2891
+ for (const m of claimedUIMessages) {
2892
+ if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
2893
+ pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
2894
+ }
2895
+ }
2896
+ locals.set(chatPendingSteerKey, pendingSteer);
2897
+ }
2932
2898
  // Write injection confirmation chunk to the stream so the frontend
2933
2899
  // knows which messages were injected and where in the response.
2934
2900
  if (injected.length > 0) {
@@ -2970,7 +2936,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2970
2936
  /* non-fatal */
2971
2937
  }
2972
2938
  }
2973
- return injected;
2939
+ return { injected, claimed: claimedUIMessages };
2974
2940
  }, {
2975
2941
  attributes: {
2976
2942
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3196,32 +3162,149 @@ export function buildSkillTools(skills) {
3196
3162
  return { loadSkill, readFile, bash };
3197
3163
  }
3198
3164
  /**
3199
- * Returns an options object ready to spread into `streamText()`.
3200
- *
3201
- * Includes `system`, `experimental_telemetry`, and any config fields
3202
- * (temperature, maxTokens, etc.) from the stored prompt.
3203
- *
3204
- * When a `registry` is provided and the prompt has a `model` string,
3205
- * the resolved `LanguageModel` is included as `model`.
3165
+ * A `streamText` with the agent's managed options already applied.
3166
+ *
3167
+ * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3168
+ * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3169
+ * because forgetting the spread silently drops the managed prompt, the skill
3170
+ * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3171
+ * conversational injection.
3172
+ *
3173
+ * Caller options win for everything the caller owns (model, messages, signal,
3174
+ * stopWhen). The three that would otherwise clobber managed behaviour are
3175
+ * merged instead of replaced:
3176
+ *
3177
+ * - `tools` are passed into the helper, so skill tools survive.
3178
+ * - `prepareStep` is composed after the managed one, so a caller's per-step
3179
+ * overrides apply on top of steering and compaction instead of disabling them.
3180
+ *
3181
+ * `system` may be set at the call site, on `chat.agent({ system })`, or
3182
+ * through `chat.prompt.set()`, but only in one of them. Setting it in two
3183
+ * places throws: no shape merges two system values on every supported
3184
+ * version, and dropping one silently is the failure this seam exists to
3185
+ * prevent. Injected instructions append to whichever one is in play.
3186
+ */
3187
+ /**
3188
+ * The agent-level managed options (`registry`, `system`, `cacheControl`,
3189
+ * `systemProviderOptions`), published for the run so that
3190
+ * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3191
+ * `streamText` saw them, and the documented spread form silently ran without
3192
+ * the agent's system prompt or model.
3193
+ */
3194
+ const chatAgentManagedConfigKey = locals.create("chat.agentManagedConfig");
3195
+ /**
3196
+ * The caller's `streamText` options merged with the agent's managed ones.
3206
3197
  *
3207
- * If no prompt has been set, returns `{}` (no-op spread).
3198
+ * Pure, and separate from the call so it can be asserted directly: everything
3199
+ * the caller did not name has to survive the merge, and the way to be sure of
3200
+ * that is to look at the merged object rather than at what the model received.
3208
3201
  */
3202
+ function buildManagedStreamTextOptions(options, config) {
3203
+ const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3204
+ /**
3205
+ * Only the three keys that collide are intercepted. Everything else, telemetry
3206
+ * included, stays in `rest` and reaches `streamText` untouched, with the
3207
+ * caller's value winning because `rest` is spread after `managed`. Pulling a
3208
+ * key out to "handle" it is how a caller's option gets silently dropped.
3209
+ */
3210
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3211
+ const managed = toStreamTextOptions({
3212
+ registry,
3213
+ system: callerSystem ?? agentSystem,
3214
+ cacheControl,
3215
+ systemProviderOptions,
3216
+ /**
3217
+ * A call site that names `tools` replaces the agent's set rather than
3218
+ * adding to it, so narrowing the tools for one call still works. Omitting
3219
+ * `tools` falls back to the agent's, which is what an `onAction`
3220
+ * regenerate needs: without it a regenerated answer can call nothing.
3221
+ */
3222
+ tools: (tools ?? agentTools),
3223
+ });
3224
+ const promptSystem = locals.get(chatPromptKey)?.text;
3225
+ /**
3226
+ * Two managed sources conflict too, not only a caller against a managed one.
3227
+ * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3228
+ * would win and `chat.agent({ system })` would go nowhere.
3229
+ */
3230
+ if (promptSystem && agentSystem) {
3231
+ throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3232
+ "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3233
+ "place, and add per-turn context with chat.inject({ role: 'system' }).");
3234
+ }
3235
+ const managedSystem = promptSystem || agentSystem;
3236
+ if (callerSystem !== undefined && managedSystem) {
3237
+ throw new Error("chat.agent: `system` is already set " +
3238
+ (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3239
+ ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3240
+ "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3241
+ }
3242
+ const managedPrepareStep = managed.prepareStep;
3243
+ if (typeof callerPrepareStep === "function") {
3244
+ managed.prepareStep = async (arg) => {
3245
+ const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3246
+ const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3247
+ return { ...(first ?? {}), ...(second ?? {}) };
3248
+ };
3249
+ }
3250
+ return { ...managed, ...rest };
3251
+ }
3252
+ /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3253
+ export const __buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3254
+ function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3255
+ const bound = (options = {}) => aiStreamText(buildManagedStreamTextOptions(options, {
3256
+ registry,
3257
+ system: agentSystem,
3258
+ cacheControl: agentCacheControl,
3259
+ systemProviderOptions: agentSystemProviderOptions,
3260
+ /** Read per call, so per-turn tools resolved after binding are included. */
3261
+ tools: locals.get(chatResolvedToolsKey),
3262
+ }));
3263
+ return bound;
3264
+ }
3209
3265
  function toStreamTextOptions(options) {
3266
+ const agentDefaults = locals.get(chatAgentManagedConfigKey);
3267
+ if (agentDefaults) {
3268
+ options = {
3269
+ registry: agentDefaults.registry,
3270
+ system: agentDefaults.system,
3271
+ cacheControl: agentDefaults.cacheControl,
3272
+ systemProviderOptions: agentDefaults.systemProviderOptions,
3273
+ ...options,
3274
+ };
3275
+ }
3210
3276
  const prompt = locals.get(chatPromptKey);
3211
3277
  const skills = locals.get(chatSkillsKey);
3212
3278
  const result = {};
3213
3279
  // Build the combined system prompt: stored prompt + skills preamble.
3214
- const promptText = prompt?.text ?? "";
3280
+ const baseSystem = options?.system;
3281
+ const baseSystemText = typeof baseSystem === "string"
3282
+ ? baseSystem
3283
+ : typeof baseSystem?.content === "string"
3284
+ ? baseSystem.content
3285
+ : "";
3286
+ const promptText = prompt?.text || baseSystemText;
3215
3287
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3216
3288
  if (promptText || skillsText) {
3217
3289
  const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
3218
- // Resolve system-prompt provider options for caching. Precedence (most
3219
- // specific wins, no deep merge): explicit `systemProviderOptions` →
3220
- // `cacheControl` sugar → `providerOptions` stored on `chat.prompt.set()`.
3290
+ /**
3291
+ * Resolve system-prompt provider options for caching. Precedence, most
3292
+ * specific first and no deep merge: explicit `systemProviderOptions`, the
3293
+ * `cacheControl` sugar, the ones carried on a structured `system` message,
3294
+ * then whatever `chat.prompt.set()` stored.
3295
+ *
3296
+ * A structured `system` counts only when its own text is the one being
3297
+ * sent. When `chat.prompt.set()` supplied the text, its provider options
3298
+ * are the ones that describe it.
3299
+ */
3300
+ const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3301
+ ? baseSystem.providerOptions
3302
+ : undefined;
3221
3303
  const systemProviderOptions = options?.systemProviderOptions ??
3222
3304
  (options?.cacheControl
3223
3305
  ? { anthropic: { cacheControl: options.cacheControl } }
3224
3306
  : undefined) ??
3307
+ baseSystemProviderOptions ??
3225
3308
  locals.get(chatPromptProviderOptionsKey);
3226
3309
  // A bare string stays a bare string (the unchanged default). With provider
3227
3310
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3230,6 +3313,88 @@ function toStreamTextOptions(options) {
3230
3313
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3231
3314
  : systemText;
3232
3315
  }
3316
+ /**
3317
+ * Append anything injected as system context, in whichever shape the installed
3318
+ * AI SDK accepts.
3319
+ *
3320
+ * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3321
+ * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3322
+ * range still spans all three, so emitting an array unconditionally would break
3323
+ * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3324
+ * a system message inside `messages` that v7 rejects.
3325
+ *
3326
+ * So: concatenate into one string when the base is a plain string, which every
3327
+ * version accepts and which loses nothing (separate blocks only matter for
3328
+ * per-block `providerOptions`). Use the array form only when the base is already
3329
+ * a structured message — that path requires v6+ regardless, because it is how
3330
+ * prompt caching marks the system block, and flattening it would silently throw
3331
+ * the cache away.
3332
+ *
3333
+ * Either way the injected text goes last: the base prompt keeps its position for
3334
+ * caching, and the addition reads as a later amendment. A changed prefix does
3335
+ * cost the first call its cache hit, on turns that actually injected.
3336
+ */
3337
+ /**
3338
+ * Consumed once per turn, not once per read, and moved out of the lane rather
3339
+ * than marked read in place.
3340
+ *
3341
+ * Per turn, because a `run()` that builds options twice (a cheap classifier
3342
+ * pass and then the answer) has to see the injection in both, and draining on
3343
+ * read hands it to whichever call ran first. Moved out, because blocks left in
3344
+ * the lane sit in front of anything injected during the same turn, and
3345
+ * clearing the lane on the next turn then destroys both. Outside a turn there
3346
+ * is no turn to scope the stash to, so the lane drains on read there.
3347
+ */
3348
+ const injectedInstructions = locals.get(chatInjectedInstructionsKey);
3349
+ const currentTurn = locals.get(chatTurnContextKey)?.turn;
3350
+ const consumedThisTurn = currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
3351
+ let injectedBlocks = [];
3352
+ if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3353
+ injectedBlocks = consumedThisTurn.blocks;
3354
+ // Anything injected since the stash was taken joins it, so an instruction
3355
+ // added after an action read the lane still reaches the real turn that
3356
+ // shares the action's turn number, rather than the one after.
3357
+ if (injectedInstructions && injectedInstructions.length > 0) {
3358
+ injectedBlocks.push(...injectedInstructions.splice(0));
3359
+ }
3360
+ }
3361
+ else if (injectedInstructions && injectedInstructions.length > 0) {
3362
+ injectedBlocks = injectedInstructions.splice(0);
3363
+ if (currentTurn !== undefined) {
3364
+ locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3365
+ }
3366
+ }
3367
+ if (injectedBlocks.length > 0) {
3368
+ const blocks = injectedBlocks;
3369
+ const injectedText = blocks
3370
+ .map((block) => (typeof block.content === "string" ? block.content : ""))
3371
+ .filter(Boolean)
3372
+ .join("\n\n");
3373
+ const base = result.system;
3374
+ if (base === undefined) {
3375
+ result.system = injectedText;
3376
+ }
3377
+ else if (typeof base === "string") {
3378
+ result.system = [base, injectedText].filter(Boolean).join("\n\n");
3379
+ }
3380
+ else {
3381
+ // Merged into the existing block rather than added as a second one. An array
3382
+ // of system blocks would keep the base block's cache entry, but ai@5 rejects
3383
+ // it outright ("Invalid prompt: system must be a string") while accepting a
3384
+ // single structured block, and this package's peer range still spans v5.
3385
+ // Choosing per version would mean resolving the installed version at runtime,
3386
+ // which is not something to build on: `import.meta.url` is illegal in this
3387
+ // package's CommonJS output, and a bundled task may have no resolvable `ai`
3388
+ // to read. One shape that works everywhere beats a cache hit.
3389
+ const baseBlock = base;
3390
+ result.system = {
3391
+ ...baseBlock,
3392
+ content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3393
+ .filter(Boolean)
3394
+ .join("\n\n"),
3395
+ };
3396
+ }
3397
+ }
3233
3398
  // Prompt-related options (only if chat.prompt.set() was called)
3234
3399
  if (prompt) {
3235
3400
  // Resolve model via registry if both are present
@@ -3284,7 +3449,7 @@ function toStreamTextOptions(options) {
3284
3449
  }
3285
3450
  // 2. Pending message injection (steering)
3286
3451
  if (taskPendingMessages) {
3287
- const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3452
+ const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3288
3453
  if (injected.length > 0) {
3289
3454
  resultMessages = [...(resultMessages ?? messages), ...injected];
3290
3455
  }
@@ -3294,18 +3459,89 @@ function toStreamTextOptions(options) {
3294
3459
  if (bgQueue && bgQueue.length > 0) {
3295
3460
  const injected = bgQueue.splice(0); // drain
3296
3461
  resultMessages = [...(resultMessages ?? messages), ...injected];
3462
+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
3463
+ pendingBackground.push({
3464
+ afterId: locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
3465
+ messages: injected,
3466
+ });
3467
+ locals.set(chatPendingBackgroundKey, pendingBackground);
3297
3468
  }
3298
3469
  return resultMessages ? { messages: resultMessages } : undefined;
3299
3470
  };
3300
3471
  }
3301
3472
  return result;
3302
3473
  }
3474
+ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3475
+ /**
3476
+ * Turn the current action into a turn.
3477
+ *
3478
+ * Return it from `onAction` after editing history. The action's own work is
3479
+ * finished first (the edit is applied and snapshotted), then a turn runs on the
3480
+ * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3481
+ * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3482
+ * advances. That gives the answer everything a turn has, the system prompt,
3483
+ * tools, steering, compaction, injected instructions and persistence, with no
3484
+ * action-specific handling.
3485
+ *
3486
+ * @example
3487
+ * ```ts
3488
+ * onAction: async ({ action }) => {
3489
+ * if (action.type === "regenerate") {
3490
+ * chat.history.slice(0, -1);
3491
+ * return chat.turn();
3492
+ * }
3493
+ * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3494
+ * },
3495
+ * ```
3496
+ */
3497
+ function chatTurn() {
3498
+ return { [actionTurnBrand]: true };
3499
+ }
3500
+ function isActionTurn(value) {
3501
+ return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3502
+ }
3503
+ /**
3504
+ * Replace, in a model lane, the run of messages one UI message contributed.
3505
+ *
3506
+ * Used when a UI message is replaced in place (a tool-approval continuation
3507
+ * merging onto the trailing assistant, a captured response reusing an existing
3508
+ * id, a partial replacing an existing message). Reconverting the whole lane
3509
+ * from the UI lane would also replace a compaction summary with the full
3510
+ * transcript and drop the model forms `pendingMessages.prepare` produced.
3511
+ *
3512
+ * The replaced message is the trailing one, so its run is the lane's tail,
3513
+ * before any steer forms appended after it this turn (`tailAfter`). If the
3514
+ * tail does not match the old message's conversion, nothing is changed and
3515
+ * `false` is returned so the caller can fall back to a full reconversion.
3516
+ */
3517
+ async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3518
+ const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3519
+ const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3520
+ const end = lane.length - tailAfter;
3521
+ const start = end - oldRun.length;
3522
+ if (start < 0 || end > lane.length)
3523
+ return false;
3524
+ if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3525
+ return false;
3526
+ lane.splice(start, oldRun.length, ...newRun);
3527
+ return true;
3528
+ }
3303
3529
  function isUIMessageStreamable(value) {
3304
3530
  return (typeof value === "object" &&
3305
3531
  value !== null &&
3306
3532
  "toUIMessageStream" in value &&
3307
3533
  typeof value.toUIMessageStream === "function");
3308
3534
  }
3535
+ const warnedHydrateMessagesDeprecated = new Set();
3536
+ function warnHydrateMessagesDeprecatedOnce(agentId) {
3537
+ if (warnedHydrateMessagesDeprecated.has(agentId))
3538
+ return;
3539
+ warnedHydrateMessagesDeprecated.add(agentId);
3540
+ console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
3541
+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
3542
+ "lets the application own the model's context, with crash recovery and durable " +
3543
+ "compaction that `hydrateMessages` never had.");
3544
+ }
3309
3545
  let warnedMissingOnAction = false;
3310
3546
  function warnMissingOnActionOnce() {
3311
3547
  if (warnedMissingOnAction)
@@ -3438,10 +3674,22 @@ function chatCustomAgent(options) {
3438
3674
  await installChatInputRouter(payload.chatId, {
3439
3675
  resuming: Boolean(payload.continuation),
3440
3676
  });
3677
+ // A custom agent's loop is the customer's, so there is no exit site the
3678
+ // SDK controls. Perform a requested close when `run()` returns, whatever
3679
+ // shape the loop had. Idempotent, so the createSession iterator having
3680
+ // already closed on its own exit costs nothing.
3681
+ const withClose = async (result) => {
3682
+ try {
3683
+ return await result;
3684
+ }
3685
+ finally {
3686
+ await performChatClose();
3687
+ }
3688
+ };
3441
3689
  // Keep the schema-free path identical to the original custom-agent
3442
3690
  // wrapper, including when userRun starts executing.
3443
3691
  if (!parseClientData) {
3444
- return userRun(payload, runOptions);
3692
+ return withClose(userRun(payload, runOptions));
3445
3693
  }
3446
3694
  const isHandoverBoot = payload.trigger === "handover-prepare";
3447
3695
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3457,7 +3705,7 @@ function chatCustomAgent(options) {
3457
3705
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3458
3706
  });
3459
3707
  if (validated.ok) {
3460
- return userRun(validated.payload, runOptions);
3708
+ return withClose(userRun(validated.payload, runOptions));
3461
3709
  }
3462
3710
  if (isHandoverBoot) {
3463
3711
  const signal = await waitForHandover({
@@ -3493,7 +3741,7 @@ function chatCustomAgent(options) {
3493
3741
  sessionId: next.output.sessionId ?? payload.sessionId,
3494
3742
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3495
3743
  };
3496
- return userRun(recoveredPayload, runOptions);
3744
+ return withClose(userRun(recoveredPayload, runOptions));
3497
3745
  },
3498
3746
  });
3499
3747
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3506,7 +3754,19 @@ function chatCustomAgent(options) {
3506
3754
  return task;
3507
3755
  }
3508
3756
  function chatAgent(options) {
3509
- 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;
3757
+ 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;
3758
+ if (hydrateMessages) {
3759
+ if (storage) {
3760
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3761
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3762
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3763
+ }
3764
+ if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
3765
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3766
+ "`loadContext`. Both would own the model's context; keep one.");
3767
+ }
3768
+ warnHydrateMessagesDeprecatedOnce(options.id);
3769
+ }
3510
3770
  const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3511
3771
  const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
3512
3772
  // chat.agent does not expose generic retry options (see docstring on
@@ -3589,6 +3849,42 @@ function chatAgent(options) {
3589
3849
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3590
3850
  // registered) — the wire is delta-only now, no longer a seed.
3591
3851
  let accumulatedMessages = [];
3852
+ /**
3853
+ * Give the model accumulator the background injections a step-boundary
3854
+ * drain handed to the model this turn, and record them for persistence.
3855
+ * Returns how many model messages were appended.
3856
+ */
3857
+ const reconcilePendingBackground = () => {
3858
+ const pending = locals.get(chatPendingBackgroundKey);
3859
+ if (!pending || pending.length === 0)
3860
+ return 0;
3861
+ locals.set(chatPendingBackgroundKey, []);
3862
+ let appended = 0;
3863
+ for (const entry of pending) {
3864
+ accumulatedMessages.push(...entry.messages);
3865
+ laneInjections.push(entry);
3866
+ appended += entry.messages.length;
3867
+ }
3868
+ return appended;
3869
+ };
3870
+ /**
3871
+ * Give the model accumulator the steering messages a drain consumed,
3872
+ * in the form the model actually received. Appended, never reconverted
3873
+ * from the UI lane, so a model-only compaction summary survives. Called
3874
+ * on both the success and the error path, before the response or the
3875
+ * partial joins the lane, so the order stays steer-then-answer.
3876
+ */
3877
+ const reconcilePendingSteer = (options) => {
3878
+ const pending = locals.get(chatPendingSteerKey);
3879
+ if (!pending || pending.length === 0)
3880
+ return [];
3881
+ locals.set(chatPendingSteerKey, []);
3882
+ for (const entry of pending) {
3883
+ accumulatedMessages.push(...entry.model);
3884
+ options?.turnNew?.push(...entry.model);
3885
+ }
3886
+ return pending;
3887
+ };
3592
3888
  // Accumulated UI messages for persistence. Mirrors the model accumulator
3593
3889
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
3594
3890
  let accumulatedUIMessages = [];
@@ -3606,7 +3902,156 @@ function chatAgent(options) {
3606
3902
  // collectively cost ~600ms on every first-message TTFC. Both reads
3607
3903
  // swallow errors internally; the agent stays available either way.
3608
3904
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
3905
+ const transcriptStorage = storage ??
3906
+ transcriptStorageOverride ??
3907
+ defaultStorage;
3908
+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
3909
+ /**
3910
+ * Who supplies the model's context each turn: the deprecated
3911
+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
3912
+ * the runtime's own transcript.
3913
+ */
3914
+ const loadContextHook = hydrateMessages
3915
+ ? (event) => hydrateMessages(event)
3916
+ : storageLoadContext
3917
+ ? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
3918
+ : undefined;
3919
+ let transcriptShadow = createTranscriptShadow([]);
3920
+ let bootTranscriptState = null;
3921
+ /**
3922
+ * True while the model lane holds a compaction summary, so it cannot be
3923
+ * rebuilt from the transcript and has to be persisted as state. Reset
3924
+ * wherever the lane is reconverted from the UI lane.
3925
+ */
3926
+ let laneCompacted = false;
3927
+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
3928
+ let laneInjections = [];
3929
+ let persistedStateSet = false;
3609
3930
  let bootSnapshot;
3931
+ let bootClientData = payload.metadata;
3932
+ if (parseClientData) {
3933
+ try {
3934
+ bootClientData = await parseClientData(payload.metadata);
3935
+ }
3936
+ catch {
3937
+ bootClientData = payload.metadata;
3938
+ }
3939
+ }
3940
+ /**
3941
+ * The `lastOutEventId` the most recent snapshot carried.
3942
+ *
3943
+ * A snapshot written outside a turn — after an action mutates history — has
3944
+ * no turn cursor of its own, and writing `undefined` there would drop the
3945
+ * resume point and make the next boot replay from further back. Retaining it
3946
+ * keeps an action's write cursor-neutral.
3947
+ */
3948
+ let lastSnapshotOutEventId;
3949
+ const storageTrigger = (trigger) => trigger === "regenerate-message"
3950
+ ? "regenerate-message"
3951
+ : trigger === "action" || trigger === "action-turn"
3952
+ ? "action"
3953
+ : "submit-message";
3954
+ /**
3955
+ * Hand the runtime's view of the transcript to the storage as a
3956
+ * changeset: the diff against what was last saved, plus the cursors the
3957
+ * next boot resumes from. The shadow only advances when the save
3958
+ * succeeds, so a failed save is folded into the next changeset.
3959
+ */
3960
+ /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
3961
+ let transcriptState = null;
3962
+ const saveTranscript = async (opts) => {
3963
+ const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
3964
+ nonFinalIds: opts.nonFinalIds,
3965
+ });
3966
+ const throughId = opts.messages.at(-1)?.id ?? "";
3967
+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
3968
+ const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
3969
+ ? {
3970
+ v: 1,
3971
+ ...(laneCompacted
3972
+ ? {
3973
+ compaction: {
3974
+ modelMessages: accumulatedMessages,
3975
+ throughId,
3976
+ },
3977
+ }
3978
+ : {}),
3979
+ ...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
3980
+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
3981
+ }
3982
+ : null;
3983
+ if (runtimeState !== null || persistedStateSet) {
3984
+ changes.push({ op: "state", value: runtimeState });
3985
+ }
3986
+ transcriptState = runtimeState;
3987
+ const inCursor = chatInputRouter().resumeFloor();
3988
+ await transcriptStorage.save({
3989
+ chatId: payload.chatId,
3990
+ clientData: opts.clientData,
3991
+ turn: opts.turn,
3992
+ trigger: opts.trigger,
3993
+ runId: ctx.run.id,
3994
+ ctx,
3995
+ }, {
3996
+ reason: opts.reason,
3997
+ changes,
3998
+ transcript: {
3999
+ entries: opts.messages.map((message) => ({
4000
+ id: message.id,
4001
+ final: !shadow.nonFinal.has(message.id),
4002
+ message,
4003
+ })),
4004
+ state: transcriptState,
4005
+ },
4006
+ cursors: {
4007
+ lastOutEventId: opts.lastOutEventId,
4008
+ lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
4009
+ },
4010
+ });
4011
+ transcriptShadow = shadow;
4012
+ persistedStateSet = runtimeState !== null;
4013
+ };
4014
+ /**
4015
+ * Persist the accumulator outside a turn.
4016
+ *
4017
+ * An action is not a turn, so it never reaches the turn-complete path where
4018
+ * the transcript is normally saved, but a `chat.history` mutation changes
4019
+ * the conversation and has to survive the run ending.
4020
+ *
4021
+ * Cursor-neutral: an action has no turn cursor of its own, and writing
4022
+ * `undefined` would drop the resume point the last turn established and make
4023
+ * the next boot replay from further back.
4024
+ */
4025
+ const writeSnapshotOutsideTurn = async (reason, turnContext) => {
4026
+ if (hydrateMessages)
4027
+ return;
4028
+ try {
4029
+ await tracer.startActiveSpan("snapshot.write", async () => {
4030
+ await saveTranscript({
4031
+ reason: "action",
4032
+ messages: accumulatedUIMessages,
4033
+ turn: turnContext.turn,
4034
+ trigger: "action",
4035
+ clientData: turnContext.clientData,
4036
+ lastOutEventId: lastSnapshotOutEventId,
4037
+ });
4038
+ }, {
4039
+ attributes: {
4040
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4041
+ [SemanticInternalAttributes.COLLAPSED]: true,
4042
+ "chat.snapshot.reason": reason,
4043
+ "chat.messages.count": accumulatedUIMessages.length,
4044
+ },
4045
+ });
4046
+ }
4047
+ catch (error) {
4048
+ logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4049
+ error: error instanceof Error ? error.message : String(error),
4050
+ sessionId: sessionIdForSnapshot,
4051
+ reason,
4052
+ });
4053
+ }
4054
+ };
3610
4055
  let replayedSettled = [];
3611
4056
  let replayedPartial;
3612
4057
  let replayedPartialRaw;
@@ -3622,7 +4067,7 @@ function chatAgent(options) {
3622
4067
  // resume-cursor block reuses it instead of re-scanning.
3623
4068
  let bootInCursor;
3624
4069
  let bootInCursorResolved = false;
3625
- if (!hydrateMessages && couldHavePriorState) {
4070
+ if (couldHavePriorState) {
3626
4071
  // Single parent span for the whole boot read phase — snapshot
3627
4072
  // read, session.out replay, session.in replay. Per-phase timing
3628
4073
  // + result counts are attributes on the span.
@@ -3630,18 +4075,33 @@ function chatAgent(options) {
3630
4075
  // snapshot read
3631
4076
  const snapStart = Date.now();
3632
4077
  try {
3633
- bootSnapshot = await readChatSnapshot(sessionIdForSnapshot);
4078
+ const loaded = hydrateMessages
4079
+ ? undefined
4080
+ : await transcriptStorage.load({
4081
+ chatId: payload.chatId,
4082
+ clientData: bootClientData,
4083
+ });
4084
+ if (loaded) {
4085
+ transcriptShadow = createTranscriptShadow(loaded.messages, new Set(loaded.nonFinalIds ?? []));
4086
+ bootTranscriptState = loaded.state;
4087
+ transcriptState = loaded.state ?? null;
4088
+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
4089
+ bootSnapshot = {
4090
+ messages: loaded.messages,
4091
+ lastOutEventId: loaded.cursors?.lastOutEventId,
4092
+ lastInEventId: loaded.cursors?.lastInEventId,
4093
+ };
4094
+ }
3634
4095
  }
3635
4096
  catch (error) {
3636
- // `readChatSnapshot` already swallows + warns internally; this catch
3637
- // is just belt-and-suspenders against tracer/span errors.
3638
- logger.warn("chat.agent: snapshot read failed; continuing without snapshot", {
4097
+ logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
3639
4098
  error: error instanceof Error ? error.message : String(error),
3640
4099
  sessionId: sessionIdForSnapshot,
3641
4100
  });
3642
4101
  }
3643
4102
  bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
3644
- bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot);
4103
+ bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
4104
+ (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
3645
4105
  bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
3646
4106
  // Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
3647
4107
  // id of the previous turn's `turn-complete` control record). The
@@ -3649,6 +4109,7 @@ function chatAgent(options) {
3649
4109
  // Without seeding, the new worker would emit no trim on its first
3650
4110
  // turn (chain self-bootstraps from turn 2), so this is purely an
3651
4111
  // optimization to keep continuation runs bounded from the first turn.
4112
+ lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
3652
4113
  if (bootSnapshot?.lastOutEventId !== undefined) {
3653
4114
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
3654
4115
  if (Number.isFinite(seeded)) {
@@ -3743,13 +4204,17 @@ function chatAgent(options) {
3743
4204
  // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3744
4205
  // only a fallback: the boot block above may already have resolved a
3745
4206
  // cursor from the snapshot, which is used when the boundary itself
3746
- // carries none.
4207
+ // carries none. Everything the boot replayed off `.in` is dispatched from
4208
+ // `bootInjectedQueue` below, so it goes into the floor here — folded in
4209
+ // after the subscription opens, the live tail re-delivers it as a turn.
4210
+ const lastRecoveredInSeq = replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1].seqNum : undefined;
3747
4211
  await installChatInputRouter(payload.chatId, {
3748
4212
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4213
+ recoveredThrough: lastRecoveredInSeq,
3749
4214
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3750
4215
  });
3751
4216
  // ── Recovery boot + chain reconstruction ────────────────────────
3752
- if (!hydrateMessages) {
4217
+ {
3753
4218
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
3754
4219
  const inFlightUsers = replayedInTail.map((r) => r.message);
3755
4220
  const partialAssistant = replayedPartial;
@@ -3853,16 +4318,6 @@ function chatAgent(options) {
3853
4318
  if (hookBeforeBoot) {
3854
4319
  await hookBeforeBoot();
3855
4320
  }
3856
- // Advance the session.in cursor past every recovered user so
3857
- // the live subscription doesn't re-deliver them.
3858
- if (replayedInTail.length > 0) {
3859
- const lastRecoveredSeq = replayedInTail[replayedInTail.length - 1].seqNum;
3860
- const currentCursor = sessionStreams.lastSeqNum(payload.chatId, "in");
3861
- if (currentCursor === undefined || lastRecoveredSeq > currentCursor) {
3862
- sessionStreams.setLastSeqNum(payload.chatId, "in", lastRecoveredSeq);
3863
- sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", lastRecoveredSeq);
3864
- }
3865
- }
3866
4321
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
3867
4322
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
3868
4323
  // dispatch as normal turns with the existing hook stack.
@@ -3912,7 +4367,8 @@ function chatAgent(options) {
3912
4367
  // path where wire-borne UIMessage[] still seeds the accumulator,
3913
4368
  // and it's safe because the route handler isn't subject to the
3914
4369
  // `/in/append` 512 KiB cap.
3915
- if (accumulatedUIMessages.length === 0 &&
4370
+ if (!loadContextHook &&
4371
+ accumulatedUIMessages.length === 0 &&
3916
4372
  payload.trigger === "handover-prepare" &&
3917
4373
  Array.isArray(payload.headStartMessages) &&
3918
4374
  payload.headStartMessages.length > 0) {
@@ -3941,7 +4397,17 @@ function chatAgent(options) {
3941
4397
  }
3942
4398
  }
3943
4399
  try {
3944
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
4400
+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
4401
+ const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4402
+ accumulatedMessages = restored.messages;
4403
+ laneCompacted = restored.compacted;
4404
+ laneInjections = restored.injections;
4405
+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4406
+ locals.set(chatBackgroundQueueKey, [
4407
+ ...(locals.get(chatBackgroundQueueKey) ?? []),
4408
+ ...bootRuntimeState.queued,
4409
+ ]);
4410
+ }
3945
4411
  }
3946
4412
  catch (error) {
3947
4413
  logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
@@ -3955,6 +4421,12 @@ function chatAgent(options) {
3955
4421
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
3956
4422
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3957
4423
  }
4424
+ locals.set(chatAgentManagedConfigKey, {
4425
+ registry: promptRegistry,
4426
+ system: agentSystem,
4427
+ cacheControl: agentCacheControl,
4428
+ systemProviderOptions: agentSystemProviderOptions,
4429
+ });
3958
4430
  // Token usage tracking across turns
3959
4431
  let previousTurnUsage;
3960
4432
  let cumulativeUsage = emptyUsage();
@@ -4312,6 +4784,7 @@ function chatAgent(options) {
4312
4784
  } // end else (no boot-injected first turn)
4313
4785
  }
4314
4786
  for (let turn = 0; turn < maxTurns; turn++) {
4787
+ let turnClientData = payload.metadata;
4315
4788
  // Declared here so the finally can detach it — a handler leaked past
4316
4789
  // its turn duplicates every mid-stream message into the shared buffer.
4317
4790
  let turnMsgSub;
@@ -4333,6 +4806,7 @@ function chatAgent(options) {
4333
4806
  // `cleanupAbortedParts` no longer has to be re-applied below.
4334
4807
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4335
4808
  const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4809
+ turnClientData = clientData;
4336
4810
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
4337
4811
  // Actions are not turns. They use a different span name
4338
4812
  // and don't carry a turn.number. Branched on at `isAction`.
@@ -4369,6 +4843,7 @@ function chatAgent(options) {
4369
4843
  locals.set(chatDeferKey, new Set());
4370
4844
  locals.set(chatCompactionStateKey, undefined);
4371
4845
  locals.set(chatSteeringQueueKey, []);
4846
+ locals.set(chatPendingBackgroundKey, []);
4372
4847
  locals.set(chatResponsePartsKey, []);
4373
4848
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
4374
4849
  // by deferred work from the previous turn's onTurnComplete need to
@@ -4450,6 +4925,10 @@ function chatAgent(options) {
4450
4925
  // Track new messages for this turn (user input + assistant response).
4451
4926
  const turnNewModelMessages = [];
4452
4927
  const turnNewUIMessages = [];
4928
+ locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
4929
+ // A head-start handover deliberately resumes from an assistant
4930
+ // message it spliced in, so it isn't a no-op turn.
4931
+ let splicedHandoverPartial = false;
4453
4932
  // ── Action handling ──────────────────────────────────────
4454
4933
  // Actions arrive on the same input stream but with
4455
4934
  // trigger === "action". They are NOT turns — only
@@ -4460,16 +4939,25 @@ function chatAgent(options) {
4460
4939
  // an action, return a `StreamTextResult` (auto-piped),
4461
4940
  // string, or UIMessage from `onAction`. Turn counter
4462
4941
  // does not advance.
4463
- let actionStreamResult = undefined;
4942
+ let actionResult = undefined;
4943
+ /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
4944
+ let actionTurn = false;
4945
+ /**
4946
+ * Whether this action changed the conversation, by rolling history
4947
+ * back or by streaming a response. Drives the single snapshot write
4948
+ * at the end — an action never reaches the turn-complete path that
4949
+ * normally does it.
4950
+ */
4951
+ let actionChangedHistory = false;
4464
4952
  if (isAction) {
4465
4953
  // Parse and validate the action payload
4466
4954
  const parsedAction = parseAction
4467
4955
  ? await parseAction(currentWirePayload.action)
4468
4956
  : currentWirePayload.action;
4469
4957
  // Hydrate messages from backend if configured
4470
- if (hydrateMessages) {
4958
+ if (loadContextHook) {
4471
4959
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
4472
- return hydrateMessages({
4960
+ return loadContextHook({
4473
4961
  chatId: currentWirePayload.chatId,
4474
4962
  turn,
4475
4963
  trigger: "action",
@@ -4489,12 +4977,14 @@ function chatAgent(options) {
4489
4977
  });
4490
4978
  accumulatedUIMessages = [...hydrated];
4491
4979
  accumulatedMessages = await toModelMessages(hydrated);
4980
+ laneCompacted = false;
4981
+ laneInjections = [];
4492
4982
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4493
4983
  }
4494
4984
  // Fire onAction — handler may mutate state via
4495
4985
  // `chat.history.*` and / or return a model response.
4496
4986
  if (onAction) {
4497
- actionStreamResult = await tracer.startActiveSpan("onAction()", async () => {
4987
+ actionResult = await tracer.startActiveSpan("onAction()", async () => {
4498
4988
  return await onAction({
4499
4989
  action: parsedAction,
4500
4990
  chatId: currentWirePayload.chatId,
@@ -4519,7 +5009,10 @@ function chatAgent(options) {
4519
5009
  locals.set(chatOverrideMessagesKey, undefined);
4520
5010
  accumulatedUIMessages = [...actionOverride];
4521
5011
  accumulatedMessages = await toModelMessages(actionOverride);
5012
+ laneCompacted = false;
5013
+ laneInjections = [];
4522
5014
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5015
+ actionChangedHistory = true;
4523
5016
  }
4524
5017
  }
4525
5018
  else {
@@ -4540,7 +5033,7 @@ function chatAgent(options) {
4540
5033
  // route handler's first-turn history to the hydrate hook as
4541
5034
  // incoming messages instead (gated on the pending handover).
4542
5035
  if (turn === 0 &&
4543
- hydrateMessages &&
5036
+ loadContextHook &&
4544
5037
  cleanedUIMessages.length === 0 &&
4545
5038
  (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
4546
5039
  Array.isArray(payload.headStartMessages) &&
@@ -4570,7 +5063,7 @@ function chatAgent(options) {
4570
5063
  },
4571
5064
  }));
4572
5065
  }
4573
- if (hydrateMessages) {
5066
+ if (loadContextHook) {
4574
5067
  // Snapshot the ids the accumulator knew BEFORE this
4575
5068
  // turn ran — used below to decide whether an
4576
5069
  // incoming wire message is genuinely new or just a
@@ -4588,7 +5081,7 @@ function chatAgent(options) {
4588
5081
  // was always true for `submit-message` is now true for every
4589
5082
  // trigger.
4590
5083
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
4591
- return hydrateMessages({
5084
+ return loadContextHook({
4592
5085
  chatId: currentWirePayload.chatId,
4593
5086
  turn,
4594
5087
  trigger: currentWirePayload.trigger,
@@ -4627,6 +5120,8 @@ function chatAgent(options) {
4627
5120
  }
4628
5121
  accumulatedUIMessages = merged;
4629
5122
  accumulatedMessages = await toModelMessages(merged);
5123
+ laneCompacted = false;
5124
+ laneInjections = [];
4630
5125
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4631
5126
  // Track new messages for onTurnComplete.newUIMessages.
4632
5127
  // Only push for genuinely new ids — HITL continuations
@@ -4672,6 +5167,8 @@ function chatAgent(options) {
4672
5167
  accumulatedUIMessages.pop();
4673
5168
  }
4674
5169
  accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5170
+ laneCompacted = false;
5171
+ laneInjections = [];
4675
5172
  }
4676
5173
  else if (cleanedUIMessages.length > 0) {
4677
5174
  // Submit-message (and the special-cased
@@ -4688,6 +5185,7 @@ function chatAgent(options) {
4688
5185
  // where AI SDK regenerates the id (TRI-9137) still
4689
5186
  // applies via `rewriteIncomingIdViaToolCallMap`.
4690
5187
  let replaced = false;
5188
+ const replacedPairs = [];
4691
5189
  for (const raw of cleanedUIMessages) {
4692
5190
  let incoming = raw;
4693
5191
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -4699,7 +5197,9 @@ function chatAgent(options) {
4699
5197
  }
4700
5198
  }
4701
5199
  if (idx !== -1) {
4702
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5200
+ const previous = accumulatedUIMessages[idx];
5201
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5202
+ replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4703
5203
  replaced = true;
4704
5204
  }
4705
5205
  else {
@@ -4709,9 +5209,19 @@ function chatAgent(options) {
4709
5209
  recordToolCallIdsFromMessage(incoming);
4710
5210
  }
4711
5211
  if (replaced) {
4712
- // Replacement changes structure — reconvert all model
4713
- // messages instead of appending.
4714
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5212
+ let inPlace = true;
5213
+ for (const { previous, merged } of replacedPairs) {
5214
+ if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5215
+ inPlace = false;
5216
+ break;
5217
+ }
5218
+ }
5219
+ if (!inPlace) {
5220
+ logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5221
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5222
+ laneCompacted = false;
5223
+ laneInjections = [];
5224
+ }
4715
5225
  }
4716
5226
  else {
4717
5227
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -4754,10 +5264,33 @@ function chatAgent(options) {
4754
5264
  messageId: locals.get(chatHandoverMessageIdKey),
4755
5265
  });
4756
5266
  locals.set(chatHandoverPartialKey, []); // consume once
5267
+ splicedHandoverPartial = true;
4757
5268
  }
4758
5269
  }
4759
5270
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4760
5271
  } // end if (trigger !== "action")
5272
+ // ── No-op turn ──────────────────────────────────────────
5273
+ //
5274
+ // A submit that added no new user message and leaves the model
5275
+ // chain ending on an assistant message has nothing to answer —
5276
+ // calling the model would prefill its own last reply. Keyed on
5277
+ // the model tail, so a `tool`-terminated chain (a merged tool
5278
+ // approval) still runs.
5279
+ const isNoOpTurn = !isAction &&
5280
+ !splicedHandoverPartial &&
5281
+ currentWirePayload.trigger === "submit-message" &&
5282
+ turnNewUIMessages.length === 0 &&
5283
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5284
+ if (isNoOpTurn) {
5285
+ msgSub?.off();
5286
+ logger.warn("chat.agent: turn added no new user message; skipping the model", {
5287
+ chatId: currentWirePayload.chatId,
5288
+ messageId: currentWirePayload.messageId,
5289
+ });
5290
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5291
+ // Not a turn — don't consume an iteration.
5292
+ turn--;
5293
+ }
4761
5294
  // ── Action result handling ──────────────────────────────
4762
5295
  // For action turns, skip the turn machinery entirely.
4763
5296
  // If `onAction` returned a stream / string / UIMessage,
@@ -4767,34 +5300,34 @@ function chatAgent(options) {
4767
5300
  // The turn counter is decremented so the next iteration
4768
5301
  // sees the same `turn` value — actions don't count.
4769
5302
  if (isAction) {
4770
- msgSub?.off();
4771
- if ((locals.get(chatPipeCountKey) ?? 0) === 0 &&
4772
- isUIMessageStreamable(actionStreamResult)) {
4773
- try {
4774
- const resolvedOptions = resolveUIMessageStreamOptions();
4775
- const uiStream = actionStreamResult.toUIMessageStream({
4776
- ...resolvedOptions,
4777
- generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
4778
- });
4779
- await pipeChat(uiStream, {
4780
- signal: combinedSignal,
4781
- spanName: "stream response",
4782
- });
5303
+ if (isActionTurn(actionResult)) {
5304
+ // Persist the edit before the turn starts, so a turn that is
5305
+ // cancelled or runs out of memory continues from the edited
5306
+ // history rather than from the snapshot the edit replaced.
5307
+ // The turn then does its own hooks, completion and snapshot.
5308
+ if (actionChangedHistory) {
5309
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4783
5310
  }
4784
- catch (error) {
4785
- if (error instanceof Error &&
4786
- error.name === "AbortError" &&
4787
- runSignal.aborted) {
4788
- return "exit";
4789
- }
4790
- throw error;
5311
+ actionTurn = true;
5312
+ }
5313
+ else if (actionResult !== undefined) {
5314
+ throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5315
+ "after the edit, return chat.turn() and a turn runs on the edited history. " +
5316
+ "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5317
+ }
5318
+ else {
5319
+ msgSub?.off();
5320
+ if (actionChangedHistory) {
5321
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4791
5322
  }
5323
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5324
+ // Don't consume a turn iteration — actions aren't turns.
5325
+ turn--;
4792
5326
  }
4793
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4794
- // Don't consume a turn iteration — actions aren't turns.
4795
- turn--;
4796
5327
  }
4797
- if (!isAction) {
5328
+ // A no-op turn skips this block, and with it `followSessionPin`:
5329
+ // there is nothing to answer, so nothing to hand over.
5330
+ if ((!isAction || actionTurn) && !isNoOpTurn) {
4798
5331
  // Mint a scoped public access token once per turn, reused for
4799
5332
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
4800
5333
  const currentRunId = ctx.run.id;
@@ -4883,6 +5416,8 @@ function chatAgent(options) {
4883
5416
  locals.set(chatOverrideMessagesKey, undefined);
4884
5417
  accumulatedUIMessages = [...turnStartOverride];
4885
5418
  accumulatedMessages = await toModelMessages(turnStartOverride);
5419
+ laneCompacted = false;
5420
+ laneInjections = [];
4886
5421
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4887
5422
  }
4888
5423
  }, {
@@ -4899,9 +5434,11 @@ function chatAgent(options) {
4899
5434
  },
4900
5435
  });
4901
5436
  }
5437
+ await followSessionPin(currentWirePayload.chatId, versionSkew);
4902
5438
  // chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
4903
- // skip run() and signal the transport to re-trigger the same message
4904
- // on the new version.
5439
+ // skip run() and hand over to a fresh run on the new version. The
5440
+ // successor picks the message up off session.in; the transport only
5441
+ // keeps reading.
4905
5442
  if (locals.get(chatUpgradeRequestedKey)) {
4906
5443
  await writeUpgradeRequiredChunk();
4907
5444
  return "exit";
@@ -4941,7 +5478,12 @@ function chatAgent(options) {
4941
5478
  const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
4942
5479
  const bgQueue = locals.get(chatBackgroundQueueKey);
4943
5480
  if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
4944
- accumulatedMessages.push(...bgQueue.splice(0));
5481
+ const injected = bgQueue.splice(0);
5482
+ accumulatedMessages.push(...injected);
5483
+ laneInjections.push({
5484
+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
5485
+ messages: injected,
5486
+ });
4945
5487
  }
4946
5488
  if (isHeadStartFinalTurn) {
4947
5489
  // The synthesized partial UIMessage IS the response.
@@ -4960,6 +5502,9 @@ function chatAgent(options) {
4960
5502
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
4961
5503
  runResult = await userRun({
4962
5504
  ...restWire,
5505
+ // A turn requested by chat.turn() is not the action itself:
5506
+ // a run() that short-circuits on "action" must still answer.
5507
+ ...(actionTurn ? { trigger: "action-turn" } : {}),
4963
5508
  messages: preparedMessages,
4964
5509
  clientData,
4965
5510
  continuation,
@@ -4972,6 +5517,7 @@ function chatAgent(options) {
4972
5517
  signal: combinedSignal,
4973
5518
  cancelSignal,
4974
5519
  stopSignal,
5520
+ streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
4975
5521
  });
4976
5522
  }
4977
5523
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5085,7 +5631,21 @@ function chatAgent(options) {
5085
5631
  if (runOverride) {
5086
5632
  locals.set(chatOverrideMessagesKey, undefined);
5087
5633
  accumulatedUIMessages = [...runOverride];
5088
- accumulatedMessages = await toModelMessages(runOverride);
5634
+ /**
5635
+ * Steers the drain consumed are left out of the rebuild and
5636
+ * appended by the reconciliation below instead, so the lane
5637
+ * gets the form the model actually received rather than a
5638
+ * reconversion of the UI message, and gets it once. A steer
5639
+ * the edit removed is dropped from the pending list too, so
5640
+ * the edit is honoured.
5641
+ */
5642
+ const overrideIds = new Set(runOverride.map((m) => m.id));
5643
+ const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5644
+ locals.set(chatPendingSteerKey, pending);
5645
+ const pendingIds = new Set(pending.map((e) => e.ui.id));
5646
+ accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5647
+ laneCompacted = false;
5648
+ laneInjections = [];
5089
5649
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5090
5650
  }
5091
5651
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5108,6 +5668,8 @@ function chatAgent(options) {
5108
5668
  accumulatedMessages = taskCompactionConfig?.compactModelMessages
5109
5669
  ? await taskCompactionConfig.compactModelMessages(compactEvent)
5110
5670
  : modelOnlyOverride;
5671
+ laneCompacted = true;
5672
+ laneInjections = [];
5111
5673
  // Apply UI messages: callback or default (preserve all)
5112
5674
  if (taskCompactionConfig?.compactUIMessages) {
5113
5675
  accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
@@ -5115,10 +5677,20 @@ function chatAgent(options) {
5115
5677
  }
5116
5678
  // Determine if the user stopped generation this turn (not a full run cancel).
5117
5679
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5680
+ // Give the model accumulator the steering messages the drain
5681
+ // consumed. Appended, never reconverted from the UI lane, so a
5682
+ // model-only compaction summary set just above survives; and done
5683
+ // before the response is appended so the order stays
5684
+ // steer-then-answer. Outside the `capturedResponseMessage`
5685
+ // branches below, so a turn that captured no response is covered.
5686
+ const steerTailThisTurn = reconcilePendingSteer({
5687
+ turnNew: turnNewModelMessages,
5688
+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
5118
5689
  // Append the assistant's response (partial or complete) to the accumulator.
5119
5690
  // The onFinish callback fires even on abort/stop, so partial responses
5120
5691
  // from stopped generation are captured correctly.
5121
5692
  let rawResponseMessage;
5693
+ let responseWasSkipped = false;
5122
5694
  if (capturedResponseMessage) {
5123
5695
  // Keep the raw message before cleanup for users who want custom handling
5124
5696
  rawResponseMessage = capturedResponseMessage;
@@ -5144,43 +5716,56 @@ function chatAgent(options) {
5144
5716
  };
5145
5717
  locals.set(chatResponsePartsKey, []);
5146
5718
  }
5147
- // Tool-approval continuations: the AI SDK reuses the trailing
5148
- // assistant's ID (via originalMessages) so the captured response
5149
- // carries the same ID as an existing message. Replace in place
5150
- // instead of pushing a duplicate. For action turns this never
5151
- // matches because originalMessages is omitted (fresh ID).
5152
- const existingIdx = capturedResponseMessage.id
5153
- ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5154
- : -1;
5155
- if (existingIdx !== -1) {
5156
- accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5157
- }
5158
- else {
5159
- accumulatedUIMessages.push(capturedResponseMessage);
5160
- }
5161
- turnNewUIMessages.push(capturedResponseMessage);
5162
- locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5163
- // Record toolCallId → head messageId so a HITL
5164
- // continuation next turn can recover the head id
5165
- // even if the AI SDK regenerates it. See
5166
- // `chatToolCallToMessageIdKey` for the full
5167
- // rationale (TRI-9137).
5168
- recordToolCallIdsFromMessage(capturedResponseMessage);
5169
- try {
5170
- const responseModelMessages = await toModelMessages([
5171
- stripProviderMetadata(capturedResponseMessage),
5172
- ]);
5719
+ const responseHasContent = capturedResponseMessage.parts.some((part) => part.type !== "step-start");
5720
+ if (responseHasContent) {
5721
+ // Tool-approval continuations: the AI SDK reuses the trailing
5722
+ // assistant's ID (via originalMessages) so the captured response
5723
+ // carries the same ID as an existing message. Replace in place
5724
+ // instead of pushing a duplicate. For action turns this never
5725
+ // matches because originalMessages is omitted (fresh ID).
5726
+ const existingIdx = capturedResponseMessage.id
5727
+ ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5728
+ : -1;
5729
+ const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5173
5730
  if (existingIdx !== -1) {
5174
- // Reconvert all model messages since we replaced rather than appended
5175
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5731
+ accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5176
5732
  }
5177
5733
  else {
5178
- accumulatedMessages.push(...responseModelMessages);
5734
+ accumulatedUIMessages.push(capturedResponseMessage);
5735
+ }
5736
+ turnNewUIMessages.push(capturedResponseMessage);
5737
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5738
+ // Record toolCallId → head messageId so a HITL
5739
+ // continuation next turn can recover the head id
5740
+ // even if the AI SDK regenerates it. See
5741
+ // `chatToolCallToMessageIdKey` for the full
5742
+ // rationale (TRI-9137).
5743
+ recordToolCallIdsFromMessage(capturedResponseMessage);
5744
+ try {
5745
+ const responseModelMessages = await toModelMessages([
5746
+ stripProviderMetadata(capturedResponseMessage),
5747
+ ]);
5748
+ if (existingIdx !== -1) {
5749
+ const ok = previousAtIdx !== undefined &&
5750
+ (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5751
+ if (!ok) {
5752
+ logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5753
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5754
+ laneCompacted = false;
5755
+ laneInjections = [];
5756
+ }
5757
+ }
5758
+ else {
5759
+ accumulatedMessages.push(...responseModelMessages);
5760
+ }
5761
+ turnNewModelMessages.push(...responseModelMessages);
5762
+ }
5763
+ catch {
5764
+ // Conversion failed — skip accumulation for this turn
5179
5765
  }
5180
- turnNewModelMessages.push(...responseModelMessages);
5181
5766
  }
5182
- catch {
5183
- // Conversion failed — skip accumulation for this turn
5767
+ else {
5768
+ responseWasSkipped = true;
5184
5769
  }
5185
5770
  }
5186
5771
  // If there's no captured response (manual pipe mode) but there are
@@ -5280,6 +5865,8 @@ function chatAgent(options) {
5280
5865
  ],
5281
5866
  },
5282
5867
  ];
5868
+ laneCompacted = true;
5869
+ laneInjections = [];
5283
5870
  // UI messages: callback or default (preserve all)
5284
5871
  if (outerCompaction.compactUIMessages) {
5285
5872
  accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
@@ -5369,6 +5956,8 @@ function chatAgent(options) {
5369
5956
  locals.set(chatOverrideMessagesKey, undefined);
5370
5957
  accumulatedUIMessages = [...override];
5371
5958
  accumulatedMessages = await toModelMessages(override);
5959
+ laneCompacted = false;
5960
+ laneInjections = [];
5372
5961
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5373
5962
  // Update event so onTurnComplete sees compacted messages
5374
5963
  turnCompleteEvent.messages = accumulatedMessages;
@@ -5397,6 +5986,19 @@ function chatAgent(options) {
5397
5986
  capturedPartialResponse = capturedResponseMessage;
5398
5987
  turnCompleteEvent.responseMessage = capturedResponseMessage;
5399
5988
  turnCompleteEvent.uiMessages = accumulatedUIMessages;
5989
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5990
+ }
5991
+ else if (responseWasSkipped) {
5992
+ capturedResponseMessage = {
5993
+ ...capturedResponseMessage,
5994
+ parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
5995
+ };
5996
+ accumulatedUIMessages.push(capturedResponseMessage);
5997
+ turnNewUIMessages.push(capturedResponseMessage);
5998
+ capturedPartialResponse = capturedResponseMessage;
5999
+ turnCompleteEvent.responseMessage = capturedResponseMessage;
6000
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
6001
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5400
6002
  }
5401
6003
  locals.set(chatResponsePartsKey, []);
5402
6004
  }
@@ -5415,6 +6017,8 @@ function chatAgent(options) {
5415
6017
  locals.set(chatOverrideMessagesKey, undefined);
5416
6018
  accumulatedUIMessages = [...turnCompleteOverride];
5417
6019
  accumulatedMessages = await toModelMessages(turnCompleteOverride);
6020
+ laneCompacted = false;
6021
+ laneInjections = [];
5418
6022
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5419
6023
  }
5420
6024
  }, {
@@ -5468,13 +6072,18 @@ function chatAgent(options) {
5468
6072
  if (!hydrateMessages) {
5469
6073
  try {
5470
6074
  await tracer.startActiveSpan("snapshot.write", async () => {
5471
- const snapshotInCursor = chatInputRouter().resumeFloor();
5472
- await writeChatSnapshot(sessionIdForSnapshot, {
5473
- version: 1,
5474
- savedAt: Date.now(),
6075
+ lastSnapshotOutEventId =
6076
+ turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6077
+ await saveTranscript({
6078
+ reason: "turn-complete",
5475
6079
  messages: accumulatedUIMessages,
5476
- lastOutEventId: turnCompleteResult?.lastEventId,
5477
- lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6080
+ turn,
6081
+ trigger: storageTrigger(currentWirePayload.trigger),
6082
+ clientData,
6083
+ lastOutEventId: lastSnapshotOutEventId,
6084
+ nonFinalIds: wasStopped && capturedResponseMessage?.id
6085
+ ? new Set([capturedResponseMessage.id])
6086
+ : undefined,
5478
6087
  });
5479
6088
  }, {
5480
6089
  attributes: {
@@ -5507,10 +6116,17 @@ function chatAgent(options) {
5507
6116
  currentWirePayload = bootInjectedQueue.shift();
5508
6117
  return "continue";
5509
6118
  }
5510
- // chat.requestUpgrade() was called — exit the loop so the
5511
- // transport triggers a new run on the latest version.
6119
+ // chat.requestUpgrade() was called — exit the loop; the handover
6120
+ // has already triggered a new run on the latest version.
5512
6121
  // chat.endRun() — same exit, no upgrade semantics.
6122
+ if (locals.get(chatCloseRequestedKey)) {
6123
+ await performChatClose();
6124
+ return "exit";
6125
+ }
5513
6126
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6127
+ if (locals.get(chatUpgradeRequestedKey)) {
6128
+ await persistUpgradeHandoff();
6129
+ }
5514
6130
  return "exit";
5515
6131
  }
5516
6132
  // Wait for the next message — stay idle briefly, then suspend
@@ -5612,11 +6228,17 @@ function chatAgent(options) {
5612
6228
  let errorTurnCompleteResult;
5613
6229
  try {
5614
6230
  await withChatWriter(async (writer) => {
5615
- const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
6231
+ const errorText = turnError instanceof Error && turnError.message
6232
+ ? turnError.message
6233
+ : "An unexpected error occurred";
5616
6234
  writer.write({ type: "error", errorText });
5617
6235
  });
5618
6236
  // Signal turn complete so the client knows this turn is done
5619
6237
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6238
+ // A later action's snapshot reuses this cursor, so it has to move
6239
+ // here too or that snapshot resumes from before the failed turn.
6240
+ lastSnapshotOutEventId =
6241
+ errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
5620
6242
  }
5621
6243
  catch {
5622
6244
  // Best-effort — if stream write fails, let the run continue anyway
@@ -5661,15 +6283,47 @@ function chatAgent(options) {
5661
6283
  : partialIdx === -1
5662
6284
  ? [...erroredUIMessages, partialResponse]
5663
6285
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
5664
- let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5665
- if (includePartial) {
5666
- erroredNewUIMessages.push(partialResponse);
5667
- }
6286
+ /**
6287
+ * Seeded from the per-turn list, not just the wire message and the
6288
+ * partial, so a steering message the drain consumed is reported too.
6289
+ * An app persisting from `newUIMessages` would otherwise lose the
6290
+ * instruction whenever the turn it steered went on to fail.
6291
+ */
6292
+ const buildErroredNew = () => {
6293
+ const out = [];
6294
+ const addUnique = (m) => {
6295
+ if (m && !out.some((existing) => existing.id === m.id))
6296
+ out.push(m);
6297
+ };
6298
+ addUnique(erroredWireMessage);
6299
+ for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6300
+ addUnique(m);
6301
+ }
6302
+ if (includePartial)
6303
+ addUnique(partialResponse);
6304
+ return out;
6305
+ };
6306
+ let erroredNewUIMessages = buildErroredNew();
5668
6307
  let erroredNewModelMessages = [];
6308
+ const reconciledSteer = reconcilePendingSteer();
6309
+ const backgroundTailThisTurn = reconcilePendingBackground();
5669
6310
  if (!responseCommitted) {
5670
6311
  try {
5671
6312
  if (erroredNewUIMessages.length > 0) {
5672
- erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6313
+ /**
6314
+ * Built in order from the recorded forms rather than by
6315
+ * converting the UI list, so a steer appears in the delta as
6316
+ * the model received it (what `prepare` produced), matching the
6317
+ * lane. The wire message and partial are converted as before.
6318
+ */
6319
+ const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6320
+ for (const m of erroredNewUIMessages) {
6321
+ const recorded = steerModelById.get(m.id);
6322
+ if (recorded)
6323
+ erroredNewModelMessages.push(...recorded);
6324
+ else
6325
+ erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6326
+ }
5673
6327
  }
5674
6328
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
5675
6329
  if (partialIdx === -1) {
@@ -5677,7 +6331,14 @@ function chatAgent(options) {
5677
6331
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
5678
6332
  }
5679
6333
  else {
5680
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6334
+ const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
6335
+ backgroundTailThisTurn);
6336
+ if (!ok) {
6337
+ logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6338
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6339
+ laneCompacted = false;
6340
+ laneInjections = [];
6341
+ }
5681
6342
  }
5682
6343
  accumulatedUIMessages = erroredUIMessagesWithPartial;
5683
6344
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -5686,7 +6347,7 @@ function chatAgent(options) {
5686
6347
  catch {
5687
6348
  erroredNewModelMessages = [];
5688
6349
  erroredUIMessagesWithPartial = erroredUIMessages;
5689
- erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6350
+ erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5690
6351
  }
5691
6352
  }
5692
6353
  if (onTurnComplete) {
@@ -5740,13 +6401,14 @@ function chatAgent(options) {
5740
6401
  // neither the snapshot nor the replayable `.in` tail.
5741
6402
  if (!hydrateMessages) {
5742
6403
  try {
5743
- const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5744
- await writeChatSnapshot(sessionIdForSnapshot, {
5745
- version: 1,
5746
- savedAt: Date.now(),
6404
+ await saveTranscript({
6405
+ reason: "turn-error",
5747
6406
  messages: erroredUIMessagesWithPartial,
5748
- lastOutEventId: errorTurnCompleteResult?.lastEventId,
5749
- lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
6407
+ turn,
6408
+ trigger: storageTrigger(currentWirePayload.trigger),
6409
+ clientData: turnClientData,
6410
+ lastOutEventId: lastSnapshotOutEventId,
6411
+ nonFinalIds: includePartial && partialResponse ? new Set([partialResponse.id]) : undefined,
5750
6412
  });
5751
6413
  }
5752
6414
  catch (error) {
@@ -5756,8 +6418,15 @@ function chatAgent(options) {
5756
6418
  });
5757
6419
  }
5758
6420
  }
6421
+ if (locals.get(chatCloseRequestedKey)) {
6422
+ await performChatClose();
6423
+ return;
6424
+ }
5759
6425
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
5760
6426
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6427
+ if (locals.get(chatUpgradeRequestedKey)) {
6428
+ await persistUpgradeHandoff();
6429
+ }
5761
6430
  return;
5762
6431
  }
5763
6432
  // Drain remaining recovered turns before idling — a thrown
@@ -5780,6 +6449,12 @@ function chatAgent(options) {
5780
6449
  return; // Timed out — end run gracefully
5781
6450
  }
5782
6451
  currentWirePayload = next.output;
6452
+ // Same close check the success path makes. Without it a close
6453
+ // record that lands after a failed turn is consumed as if it were
6454
+ // a turn payload, and the loop runs on against a closed session.
6455
+ if (currentWirePayload.trigger === "close") {
6456
+ return;
6457
+ }
5783
6458
  // Continue to next iteration of the for loop
5784
6459
  }
5785
6460
  finally {
@@ -5788,6 +6463,11 @@ function chatAgent(options) {
5788
6463
  }
5789
6464
  }
5790
6465
  finally {
6466
+ // Safety net for a close requested on a path that exits without
6467
+ // reaching one of the loop's close checks (a turn timeout, an OOM
6468
+ // re-throw). `performChatClose` is idempotent, so the ordinary path
6469
+ // having already run it costs nothing here.
6470
+ await performChatClose();
5791
6471
  // `stopSub` is registered post-preload so the close-during-preload
5792
6472
  // early-return path may exit before it ever attached. Guard the
5793
6473
  // cleanup so a missing subscription doesn't throw.
@@ -6125,15 +6805,22 @@ function isStopped() {
6125
6805
  // Version upgrade
6126
6806
  // ---------------------------------------------------------------------------
6127
6807
  /**
6128
- * Request that the current run exits so the next message starts on the latest
6129
- * deployed version (via the standard continuation mechanism).
6808
+ * Hand the conversation over to another deployment.
6809
+ *
6810
+ * The handover happens immediately and server-side: a successor run is created
6811
+ * and picks the conversation up from `session.in`. The transport keeps reading
6812
+ * the same session output, so no client action is needed and nothing waits for
6813
+ * the next message.
6814
+ *
6815
+ * Without a target the session's pin is cleared, so the successor lands on the
6816
+ * latest deployed version; with `externalDeploymentId` the session is re-pinned
6817
+ * to that deployment.
6130
6818
  *
6131
6819
  * When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped
6132
- * entirely — the run exits immediately and the transport re-triggers the
6133
- * same message on the new version.
6820
+ * entirely and the successor answers the message that opened the turn.
6134
6821
  *
6135
6822
  * When called from `run()` or `chat.defer()`, the current turn completes
6136
- * normally and the run exits afterward instead of waiting for the next message.
6823
+ * normally and the handover happens afterward.
6137
6824
  *
6138
6825
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6139
6826
  * or inside `chat.defer()`.
@@ -6153,8 +6840,37 @@ function isStopped() {
6153
6840
  * });
6154
6841
  * ```
6155
6842
  */
6156
- function requestUpgrade() {
6843
+ function requestUpgrade(options) {
6157
6844
  locals.set(chatUpgradeRequestedKey, true);
6845
+ // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
6846
+ const target = options?.externalDeploymentId?.trim();
6847
+ if (target)
6848
+ locals.set(chatUpgradeExternalDeploymentIdKey, target);
6849
+ }
6850
+ /** @internal Requests a handoff when the session's pin no longer names this deployment. */
6851
+ async function followSessionPin(chatId, policy) {
6852
+ if (!chatId) {
6853
+ return;
6854
+ }
6855
+ const deployedExternalId = locals.get(chatAgentRunContextKey)?.deployment?.externalId;
6856
+ if (policy !== "hold" && !deployedExternalId) {
6857
+ logger.debug("chat.versionSkew: cannot follow the session pin", {
6858
+ chatId,
6859
+ reason: "the run context carries no deployment.externalId",
6860
+ });
6861
+ }
6862
+ const target = await resolvePinToFollow({
6863
+ policy,
6864
+ deployedExternalId,
6865
+ upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
6866
+ readPin: async () => (await sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
6867
+ .triggerConfig,
6868
+ });
6869
+ if (!target) {
6870
+ return;
6871
+ }
6872
+ logger.info("chat.versionSkew: following the session pin", { chatId, target });
6873
+ requestUpgrade({ externalDeploymentId: target });
6158
6874
  }
6159
6875
  /**
6160
6876
  * Hand off the current custom agent Session to a fresh run.
@@ -6193,20 +6909,31 @@ async function endAndContinue() {
6193
6909
  if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6194
6910
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6195
6911
  }
6196
- await performEndAndContinue();
6912
+ await performEndAndContinue({ reason: "continuation" });
6197
6913
  }
6198
6914
  /** @internal Shared server handoff used by managed and custom agent loops. */
6199
- async function performEndAndContinue() {
6915
+ async function performEndAndContinue(options) {
6200
6916
  const chatId = locals.get(chatExternalIdKey);
6201
6917
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
6202
6918
  if (!chatId || !callingRunId) {
6203
6919
  throw new Error("Cannot end and continue without an active chat agent run");
6204
6920
  }
6921
+ const externalDeploymentId = options.externalDeploymentId;
6205
6922
  const apiClient = apiClientManager.clientOrThrow();
6206
- await apiClient.endAndContinueSession(chatId, {
6923
+ const result = await apiClient.endAndContinueSession(chatId, {
6207
6924
  callingRunId,
6208
- reason: "upgrade",
6925
+ reason: options.reason,
6926
+ ...(externalDeploymentId ? { externalDeploymentId } : {}),
6209
6927
  });
6928
+ if (result?.pendingVersion !== true) {
6929
+ return;
6930
+ }
6931
+ // The successor parked. Say so on `.out` while this run still can — the transport's
6932
+ // subscription survives the swap, so the client learns without waiting for its next send.
6933
+ const [error] = await tryCatch(getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
6934
+ if (error) {
6935
+ logger.warn("could not signal a parked handoff", { chatId, error });
6936
+ }
6210
6937
  }
6211
6938
  /**
6212
6939
  * Exit the run after the current turn completes, without waiting for the
@@ -6237,6 +6964,124 @@ async function performEndAndContinue() {
6237
6964
  function endRun() {
6238
6965
  locals.set(chatEndRunRequestedKey, true);
6239
6966
  }
6967
+ /**
6968
+ * End the whole conversation, permanently. The session row is closed, further
6969
+ * appends are refused, and the run exits without scheduling a continuation.
6970
+ *
6971
+ * This is the session-level stop. {@link endRun} ends the current run and lets
6972
+ * the next message start a fresh one; `chat.close()` ends the session itself,
6973
+ * so there is no next message. Use it for a budget cap, a completed goal,
6974
+ * abuse detection, or a user signing out.
6975
+ *
6976
+ * In a `chat.agent`, call it from `run()`, `prepareStep`, or
6977
+ * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
6978
+ * `streamText` the same way the stop signal does, so the partial response is
6979
+ * still captured and streamed. The turn then completes normally, a terminal
6980
+ * `session-closed` record carrying `reason` is written to the response stream,
6981
+ * and the loop exits.
6982
+ *
6983
+ * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
6984
+ * fields but still runs while the stream is open, so the closed state rides
6985
+ * out on the turn's final record. `onTurnComplete` runs after that record, so
6986
+ * a close decided there does not reach a reader that has already finished the
6987
+ * turn, and the user only finds out when their next message is refused.
6988
+ *
6989
+ * In a `chat.customAgent`, call it anywhere in your own loop. The close is
6990
+ * performed when `run()` returns, so it lands whether you break out of a
6991
+ * `chat.createSession` loop, return early, or hand-roll the loop entirely.
6992
+ *
6993
+ * Closing is one-way: a closed session cannot be reopened. Its transcript
6994
+ * stays readable.
6995
+ *
6996
+ * @example
6997
+ * ```ts
6998
+ * chat.agent({
6999
+ * id: "budgeted-agent",
7000
+ * onBeforeTurnComplete: async ({ usage }) => {
7001
+ * if (await overBudget(usage)) {
7002
+ * chat.close({ reason: "Monthly budget reached" });
7003
+ * }
7004
+ * },
7005
+ * });
7006
+ * ```
7007
+ */
7008
+ function close(options) {
7009
+ if (!locals.get(chatExternalIdKey)) {
7010
+ throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
7011
+ }
7012
+ // Bound the reason once, here. It goes out on S2 record headers as well as
7013
+ // the close API, and an oversized value would fail the turn-complete write
7014
+ // that carries the turn boundary, costing the client far more than the
7015
+ // reason text.
7016
+ // Trailing high surrogate: the cut landed between the two halves of an
7017
+ // astral character, and encoding the orphan to UTF-8 for a record header
7018
+ // yields a replacement character. Drop it rather than ship mojibake.
7019
+ const reason = options?.reason
7020
+ ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
7021
+ .replace(/[\uD800-\uDBFF]$/, "");
7022
+ locals.set(chatCloseRequestedKey, reason ? { reason } : {});
7023
+ // Mid-step call: unblock the in-flight streamText exactly like the stop
7024
+ // signal, so the turn can reach its turn boundary instead of running the
7025
+ // model out to completion after the decision to close has been made.
7026
+ locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
7027
+ }
7028
+ /**
7029
+ * @internal Terminal close sequence, run once at whichever exit site observes
7030
+ * the close request. Writes the standalone `session-closed` record, then closes
7031
+ * the session row.
7032
+ *
7033
+ * The record lands after the turn's `turn-complete`, so a client reading that
7034
+ * turn's stream has already terminated on it and will not see this one. It is
7035
+ * there for a reconnect and for replay. What a live client reads is the
7036
+ * `session-closed` header stamped onto `turn-complete` itself by
7037
+ * `writeTurnCompleteChunk`, which fires whenever the close was decided before
7038
+ * the turn ended. A close decided from `onTurnComplete` is past that point, so
7039
+ * the client learns from the 409 on its next send.
7040
+ */
7041
+ async function performChatClose() {
7042
+ const request = locals.get(chatCloseRequestedKey);
7043
+ if (!request || locals.get(chatClosePerformedKey))
7044
+ return;
7045
+ const reason = request.reason;
7046
+ // Two flags, not one. The record is a client-visible event and must not be
7047
+ // written twice, but the row close is the part that actually ends the
7048
+ // conversation: flagging it as done before it succeeds would let a transient
7049
+ // failure leave the session open with no later call willing to retry.
7050
+ if (!locals.get(chatCloseRecordWrittenKey)) {
7051
+ locals.set(chatCloseRecordWrittenKey, true);
7052
+ try {
7053
+ const session = getChatSession();
7054
+ await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
7055
+ }
7056
+ catch (error) {
7057
+ logger.warn("chat.close: failed to write the session-closed record", {
7058
+ error: error instanceof Error ? error.message : String(error),
7059
+ });
7060
+ }
7061
+ }
7062
+ const chatId = locals.get(chatExternalIdKey);
7063
+ if (!chatId)
7064
+ return;
7065
+ try {
7066
+ await sessions.close(chatId, {
7067
+ ...(reason ? { reason } : {}),
7068
+ ...(locals.get(chatAgentRunContextKey)?.run.id
7069
+ ? { callingRunId: locals.get(chatAgentRunContextKey).run.id }
7070
+ : {}),
7071
+ });
7072
+ locals.set(chatClosePerformedKey, true);
7073
+ }
7074
+ catch (error) {
7075
+ // Deliberately NOT flagged as performed: the close API is idempotent, so a
7076
+ // later exit site on this run gets to retry it. Losing every retry to a
7077
+ // transient failure would leave the row open and the conversation alive.
7078
+ // Non-fatal either way — the run still exits.
7079
+ logger.error("chat.close: failed to close the session", {
7080
+ chatId,
7081
+ error: error instanceof Error ? error.message : String(error),
7082
+ });
7083
+ }
7084
+ }
6240
7085
  // ---------------------------------------------------------------------------
6241
7086
  // Per-turn deferred work
6242
7087
  // ---------------------------------------------------------------------------
@@ -6298,9 +7143,18 @@ function chatDefer(promiseOrFn) {
6298
7143
  * ```
6299
7144
  */
6300
7145
  function injectBackgroundContext(messages) {
6301
- const queue = locals.get(chatBackgroundQueueKey) ?? [];
6302
- queue.push(...messages);
6303
- locals.set(chatBackgroundQueueKey, queue);
7146
+ const systemBlocks = messages.filter((message) => message.role === "system");
7147
+ const conversational = messages.filter((message) => message.role !== "system");
7148
+ if (systemBlocks.length > 0) {
7149
+ const instructions = locals.get(chatInjectedInstructionsKey) ?? [];
7150
+ instructions.push(...systemBlocks);
7151
+ locals.set(chatInjectedInstructionsKey, instructions);
7152
+ }
7153
+ if (conversational.length > 0) {
7154
+ const queue = locals.get(chatBackgroundQueueKey) ?? [];
7155
+ queue.push(...conversational);
7156
+ locals.set(chatBackgroundQueueKey, queue);
7157
+ }
6304
7158
  }
6305
7159
  // ---------------------------------------------------------------------------
6306
7160
  // Aborted message cleanup
@@ -6713,10 +7567,12 @@ class ChatMessageAccumulator {
6713
7567
  // a duplicate, mirroring the chat.agent accumulator.
6714
7568
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
6715
7569
  if (existingIdx !== -1) {
7570
+ const previous = this.uiMessages[existingIdx];
6716
7571
  this.uiMessages[existingIdx] = response;
6717
7572
  try {
6718
- // Reconvert all model messages since we replaced rather than appended.
6719
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7573
+ if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7574
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7575
+ }
6720
7576
  }
6721
7577
  catch {
6722
7578
  // Conversion failed — leave the existing model messages in place
@@ -6752,6 +7608,28 @@ class ChatMessageAccumulator {
6752
7608
  const modelMsgs = await toModelMessages([message]);
6753
7609
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
6754
7610
  }
7611
+ /**
7612
+ * Record the messages a steering drain consumed.
7613
+ *
7614
+ * The drain only puts them in this step's prompt, so without this they
7615
+ * shape one answer and then exist in neither lane: not in `uiMessages`,
7616
+ * which is what an app persists from, and not in `modelMessages`, which is
7617
+ * what every later turn sends.
7618
+ *
7619
+ * Both lanes are appended to. The model lane is never reconverted from the
7620
+ * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7621
+ * the UI lane whole: a reconversion would restore everything the summary
7622
+ * replaced.
7623
+ */
7624
+ async absorbSteering(claimed, injected) {
7625
+ const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7626
+ if (fresh.length === 0)
7627
+ return;
7628
+ this.uiMessages.push(...fresh);
7629
+ // Record what the model received. Only when the whole batch is new is
7630
+ // `injected` known to describe exactly these messages.
7631
+ this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7632
+ }
6755
7633
  /**
6756
7634
  * Get and clear unconsumed steering messages.
6757
7635
  */
@@ -6784,7 +7662,8 @@ class ChatMessageAccumulator {
6784
7662
  }
6785
7663
  // 2. Pending message injection
6786
7664
  if (pm && queue.length > 0) {
6787
- const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7665
+ const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7666
+ await this.absorbSteering(claimed, injected);
6788
7667
  if (injected.length > 0) {
6789
7668
  resultMessages = [...(resultMessages ?? messages), ...injected];
6790
7669
  }
@@ -6973,7 +7852,7 @@ function trackActiveChatSessionIterator(iterator) {
6973
7852
  * ```
6974
7853
  */
6975
7854
  function createChatSession(payload, options) {
6976
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
7855
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6977
7856
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
6978
7857
  return {
6979
7858
  [Symbol.asyncIterator]() {
@@ -7062,8 +7941,16 @@ function createChatSession(payload, options) {
7062
7941
  * without suspending.
7063
7942
  */
7064
7943
  if (turn > 0) {
7944
+ if (locals.get(chatCloseRequestedKey)) {
7945
+ await performChatClose();
7946
+ stop.cleanup();
7947
+ return { done: true, value: undefined };
7948
+ }
7065
7949
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7066
7950
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
7951
+ if (locals.get(chatUpgradeRequestedKey)) {
7952
+ await persistUpgradeHandoff();
7953
+ }
7067
7954
  stop.cleanup();
7068
7955
  return { done: true, value: undefined };
7069
7956
  }
@@ -7166,6 +8053,7 @@ function createChatSession(payload, options) {
7166
8053
  }
7167
8054
  accumulator.applyHandover(pendingHandoverSignal);
7168
8055
  }
8056
+ await followSessionPin(currentPayload.chatId, sessionVersionSkew);
7169
8057
  // chat.requestUpgrade() called before this turn — signal transport and exit
7170
8058
  if (locals.get(chatUpgradeRequestedKey)) {
7171
8059
  await writeUpgradeRequiredChunk();
@@ -7382,7 +8270,8 @@ function createChatSession(payload, options) {
7382
8270
  }
7383
8271
  }
7384
8272
  if (sessionPendingMessages) {
7385
- const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8273
+ const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8274
+ await accumulator.absorbSteering(claimed, injected);
7386
8275
  if (injected.length > 0) {
7387
8276
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
7388
8277
  }
@@ -7396,6 +8285,11 @@ function createChatSession(payload, options) {
7396
8285
  async return() {
7397
8286
  activeMsgSub?.off();
7398
8287
  activeMsgSub = undefined;
8288
+ // Reached when the consumer leaves the `for await` early (`break`,
8289
+ // `return`, a throw). A `chat.close()` from the loop body would
8290
+ // otherwise be dropped: the exit that performs it lives in `next()`,
8291
+ // and `next()` is never called again.
8292
+ await performChatClose();
7399
8293
  // `stop` only exists once next() has booted the iterator.
7400
8294
  stop?.cleanup();
7401
8295
  return { done: true, value: undefined };
@@ -7652,6 +8546,11 @@ function createChatStartSessionAction(taskId, options) {
7652
8546
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
7653
8547
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
7654
8548
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8549
+ // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8550
+ // action default, which neither truthiness nor `??` would allow.
8551
+ const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8552
+ ? params.triggerConfig.externalDeploymentId
8553
+ : options?.triggerConfig?.externalDeploymentId;
7655
8554
  const triggerConfig = {
7656
8555
  basePayload: {
7657
8556
  messages: [],
@@ -7678,6 +8577,10 @@ function createChatStartSessionAction(taskId, options) {
7678
8577
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
7679
8578
  }
7680
8579
  : {}),
8580
+ ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8581
+ ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8582
+ : {}),
8583
+ ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
7681
8584
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
7682
8585
  };
7683
8586
  const startBody = {
@@ -7722,6 +8625,7 @@ function createChatStartSessionAction(taskId, options) {
7722
8625
  publicAccessToken,
7723
8626
  runId: created.runId,
7724
8627
  sessionId: created.id,
8628
+ ...(created.pendingVersion ? { pendingVersion: true } : {}),
7725
8629
  };
7726
8630
  };
7727
8631
  }
@@ -7755,7 +8659,8 @@ async function callSessionsCreateWithOverride(args) {
7755
8659
  const init = {
7756
8660
  method: "POST",
7757
8661
  headers: overrideRequestHeaders(accessToken),
7758
- body: JSON.stringify(args.body),
8662
+ // This path bypasses `sessions.start`, so it resolves the pin itself.
8663
+ body: JSON.stringify(withResolvedExternalDeploymentId(args.body)),
7759
8664
  };
7760
8665
  const response = args.fetchOverride
7761
8666
  ? await args.fetchOverride(url, init, ctx)
@@ -7820,6 +8725,41 @@ async function mintPublicTokenWithOverride(args) {
7820
8725
  expirationTime: args.expirationTime,
7821
8726
  });
7822
8727
  }
8728
+ /**
8729
+ * Creates a server-side helper that reads a conversation from a transcript
8730
+ * storage, for rendering history before the chat connects. Works the same
8731
+ * for every storage, the platform default included, so the browser never
8732
+ * reads a store directly and the secret key stays on the server.
8733
+ *
8734
+ * Wrap it in a Next.js server action (or any server-side handler), scope it
8735
+ * to the authenticated user through `clientData`, and pass the result to
8736
+ * `useLoadTranscript` in the browser.
8737
+ *
8738
+ * @example
8739
+ * ```ts
8740
+ * // actions.ts
8741
+ * "use server";
8742
+ * import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
8743
+ *
8744
+ * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
8745
+ * ```
8746
+ */
8747
+ function createChatLoadTranscriptAction(storage, options) {
8748
+ return async (params) => {
8749
+ if (!params.chatId) {
8750
+ throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
8751
+ }
8752
+ if (options?.apiClient) {
8753
+ const { apiClient, ...rest } = options;
8754
+ return apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
8755
+ }
8756
+ const limit = params.limit ?? options?.limit;
8757
+ return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
8758
+ ...(limit !== undefined ? { limit } : {}),
8759
+ ...(params.before !== undefined ? { before: params.before } : {}),
8760
+ });
8761
+ };
8762
+ }
7823
8763
  export const chat = {
7824
8764
  /** Create a chat agent. See {@link chatAgent}. */
7825
8765
  agent: chatAgent,
@@ -7831,8 +8771,12 @@ export const chat = {
7831
8771
  withClientData,
7832
8772
  /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
7833
8773
  createStartSessionAction: createChatStartSessionAction,
8774
+ /** Returns a server-side helper that reads a conversation from a transcript storage. */
8775
+ createLoadTranscriptAction: createChatLoadTranscriptAction,
7834
8776
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
7835
8777
  pipe: pipeChat,
8778
+ /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
8779
+ turn: chatTurn,
7836
8780
  /** Create a per-run typed local. See {@link chatLocal}. */
7837
8781
  local: chatLocal,
7838
8782
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -7853,6 +8797,8 @@ export const chat = {
7853
8797
  endAndContinue,
7854
8798
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7855
8799
  endRun,
8800
+ /** End the conversation permanently: close the session and exit the run. See {@link close}. */
8801
+ close,
7856
8802
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
7857
8803
  cleanupAbortedParts,
7858
8804
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
@@ -8000,6 +8946,16 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8000
8946
  if (consumedCursor !== undefined) {
8001
8947
  extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8002
8948
  }
8949
+ // A close decided before this turn ended rides out on turn-complete. Readers
8950
+ // terminate their stream on turn-complete, so a standalone record written
8951
+ // after it only reaches a reconnect — this header is what a live client sees.
8952
+ const pendingClose = locals.get(chatCloseRequestedKey);
8953
+ if (pendingClose) {
8954
+ extraHeaders.push([SESSION_CLOSED_HEADER, "true"]);
8955
+ if (pendingClose.reason) {
8956
+ extraHeaders.push([SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
8957
+ }
8958
+ }
8003
8959
  const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8004
8960
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8005
8961
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8057,12 +9013,47 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8057
9013
  *
8058
9014
  * @internal
8059
9015
  */
9016
+ /**
9017
+ * Persists an upgrade requested after the turn has already run.
9018
+ *
9019
+ * The pre-turn sites reach {@link performEndAndContinue} through
9020
+ * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
9021
+ * session's stored `externalDeploymentId`. The post-turn exits had no such path,
9022
+ * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
9023
+ * and every continuation re-pinned to the deployment the agent asked to leave.
9024
+ *
9025
+ * No `upgrade-required` chunk is written here: the turn already produced its
9026
+ * answer, so there is nothing for a client to be told about.
9027
+ */
9028
+ async function persistUpgradeHandoff() {
9029
+ const chatId = locals.get(chatExternalIdKey);
9030
+ const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
9031
+ if (!chatId || !callingRunId) {
9032
+ return;
9033
+ }
9034
+ try {
9035
+ await performEndAndContinue({
9036
+ reason: "upgrade",
9037
+ externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9038
+ });
9039
+ }
9040
+ catch (error) {
9041
+ logger.warn("upgrade handoff failed; session keeps its current version pin", {
9042
+ chatId,
9043
+ callingRunId,
9044
+ error,
9045
+ });
9046
+ }
9047
+ }
8060
9048
  async function writeUpgradeRequiredChunk() {
8061
9049
  const chatId = locals.get(chatExternalIdKey);
8062
9050
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
8063
9051
  if (chatId && callingRunId) {
8064
9052
  try {
8065
- await performEndAndContinue();
9053
+ await performEndAndContinue({
9054
+ reason: "upgrade",
9055
+ externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9056
+ });
8066
9057
  }
8067
9058
  catch (error) {
8068
9059
  // Non-fatal: the next `.in/append` re-triggers via the probe.