@trigger.dev/sdk 4.5.15 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +2 -0
  3. package/dist/commonjs/v3/ai-shared.d.ts +15 -0
  4. package/dist/commonjs/v3/ai-shared.js +35 -1
  5. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  6. package/dist/commonjs/v3/ai.d.ts +311 -25
  7. package/dist/commonjs/v3/ai.js +1633 -380
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/auth.d.ts +10 -4
  10. package/dist/commonjs/v3/auth.js.map +1 -1
  11. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  12. package/dist/commonjs/v3/chat-client.js +227 -111
  13. package/dist/commonjs/v3/chat-client.js.map +1 -1
  14. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  15. package/dist/commonjs/v3/chat-react.js +130 -1
  16. package/dist/commonjs/v3/chat-react.js.map +1 -1
  17. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  18. package/dist/commonjs/v3/chat-server.js +52 -7
  19. package/dist/commonjs/v3/chat-server.js.map +1 -1
  20. package/dist/commonjs/v3/chat.d.ts +92 -10
  21. package/dist/commonjs/v3/chat.js +328 -73
  22. package/dist/commonjs/v3/chat.js.map +1 -1
  23. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  24. package/dist/commonjs/v3/chatSnapshotIo.js +173 -0
  25. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  26. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  27. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  28. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  29. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  30. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  31. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  32. package/dist/commonjs/v3/retry.d.ts +4 -0
  33. package/dist/commonjs/v3/retry.js +22 -13
  34. package/dist/commonjs/v3/retry.js.map +1 -1
  35. package/dist/commonjs/v3/sessions.d.ts +15 -2
  36. package/dist/commonjs/v3/sessions.js +12 -1
  37. package/dist/commonjs/v3/sessions.js.map +1 -1
  38. package/dist/commonjs/v3/shared.js +30 -36
  39. package/dist/commonjs/v3/shared.js.map +1 -1
  40. package/dist/commonjs/v3/test/index.d.ts +1 -0
  41. package/dist/commonjs/v3/test/index.js +3 -1
  42. package/dist/commonjs/v3/test/index.js.map +1 -1
  43. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  44. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  45. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  46. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  47. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  48. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  49. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  50. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  51. package/dist/commonjs/v3/transcriptStorage.d.ts +261 -0
  52. package/dist/commonjs/v3/transcriptStorage.js +374 -0
  53. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  54. package/dist/commonjs/version.js +1 -1
  55. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  56. package/dist/esm/imports/ai-runtime.js +2 -2
  57. package/dist/esm/imports/ai-runtime.js.map +1 -1
  58. package/dist/esm/v3/ai-shared.d.ts +15 -0
  59. package/dist/esm/v3/ai-shared.js +33 -0
  60. package/dist/esm/v3/ai-shared.js.map +1 -1
  61. package/dist/esm/v3/ai.d.ts +311 -25
  62. package/dist/esm/v3/ai.js +1625 -377
  63. package/dist/esm/v3/ai.js.map +1 -1
  64. package/dist/esm/v3/auth.d.ts +10 -4
  65. package/dist/esm/v3/auth.js.map +1 -1
  66. package/dist/esm/v3/chat-client.d.ts +11 -2
  67. package/dist/esm/v3/chat-client.js +228 -112
  68. package/dist/esm/v3/chat-client.js.map +1 -1
  69. package/dist/esm/v3/chat-react.d.ts +88 -0
  70. package/dist/esm/v3/chat-react.js +127 -1
  71. package/dist/esm/v3/chat-react.js.map +1 -1
  72. package/dist/esm/v3/chat-server.d.ts +42 -6
  73. package/dist/esm/v3/chat-server.js +53 -8
  74. package/dist/esm/v3/chat-server.js.map +1 -1
  75. package/dist/esm/v3/chat.d.ts +92 -10
  76. package/dist/esm/v3/chat.js +329 -74
  77. package/dist/esm/v3/chat.js.map +1 -1
  78. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  79. package/dist/esm/v3/chatSnapshotIo.js +165 -0
  80. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  81. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  82. package/dist/esm/v3/chatVersionSkew.js +27 -0
  83. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  84. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  85. package/dist/esm/v3/externalDeploymentId.js +38 -0
  86. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  87. package/dist/esm/v3/retry.d.ts +4 -0
  88. package/dist/esm/v3/retry.js +14 -8
  89. package/dist/esm/v3/retry.js.map +1 -1
  90. package/dist/esm/v3/sessions.d.ts +15 -2
  91. package/dist/esm/v3/sessions.js +11 -1
  92. package/dist/esm/v3/sessions.js.map +1 -1
  93. package/dist/esm/v3/shared.js +17 -23
  94. package/dist/esm/v3/shared.js.map +1 -1
  95. package/dist/esm/v3/test/index.d.ts +1 -0
  96. package/dist/esm/v3/test/index.js +1 -0
  97. package/dist/esm/v3/test/index.js.map +1 -1
  98. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  99. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  100. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  101. package/dist/esm/v3/test/test-session-handle.js +6 -0
  102. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  103. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  104. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  105. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  106. package/dist/esm/v3/transcriptStorage.d.ts +261 -0
  107. package/dist/esm/v3/transcriptStorage.js +360 -0
  108. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  109. package/dist/esm/version.js +1 -1
  110. package/docs/ai-chat/actions.mdx +55 -23
  111. package/docs/ai-chat/anatomy.mdx +47 -27
  112. package/docs/ai-chat/backend.mdx +125 -48
  113. package/docs/ai-chat/background-injection.mdx +122 -19
  114. package/docs/ai-chat/client-protocol.mdx +5 -4
  115. package/docs/ai-chat/compaction.mdx +13 -7
  116. package/docs/ai-chat/custom-agents.mdx +23 -0
  117. package/docs/ai-chat/fast-starts.mdx +27 -20
  118. package/docs/ai-chat/frontend.mdx +19 -14
  119. package/docs/ai-chat/how-it-works.mdx +1 -1
  120. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  121. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  122. package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
  123. package/docs/ai-chat/patterns/database-persistence.mdx +24 -9
  124. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  125. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  126. package/docs/ai-chat/patterns/skills.mdx +7 -10
  127. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  128. package/docs/ai-chat/pending-messages.mdx +3 -3
  129. package/docs/ai-chat/prompt-caching.mdx +23 -25
  130. package/docs/ai-chat/quick-start.mdx +11 -11
  131. package/docs/ai-chat/reference.mdx +69 -10
  132. package/docs/ai-chat/sessions.mdx +6 -1
  133. package/docs/ai-chat/side-channels.mdx +1 -1
  134. package/docs/ai-chat/testing.mdx +2 -1
  135. package/docs/ai-chat/tools.mdx +18 -13
  136. package/docs/ai-chat/transcript-storage.mdx +247 -0
  137. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  138. package/docs/apikeys.mdx +27 -45
  139. package/docs/config/extensions/custom.mdx +14 -1
  140. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  141. package/docs/deploy-environment-variables.mdx +23 -2
  142. package/docs/deployment/overview.mdx +4 -8
  143. package/docs/deployment/preview-branches.mdx +4 -4
  144. package/docs/deployment/version-skew-protection.mdx +62 -0
  145. package/docs/github-integration.mdx +1 -1
  146. package/docs/introduction.mdx +58 -152
  147. package/docs/manual-setup.mdx +7 -7
  148. package/docs/mcp-tools.mdx +9 -0
  149. package/docs/quick-start.mdx +3 -3
  150. package/docs/realtime/auth.mdx +1 -1
  151. package/docs/reports.mdx +157 -0
  152. package/docs/self-hosting/security.mdx +17 -0
  153. package/docs/tasks/scheduled.mdx +24 -0
  154. package/docs/triggering.mdx +1 -1
  155. package/docs/troubleshooting-zod.mdx +158 -0
  156. package/docs/troubleshooting.mdx +1 -1
  157. package/package.json +4 -4
  158. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  159. package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
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, fingerprintMessage, 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
@@ -822,31 +660,158 @@ export const ai = {
822
660
  function createChatAccessToken(taskId) {
823
661
  return auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
824
662
  }
825
- // ---------------------------------------------------------------------------
826
- // Chat transport helpers — backend side
827
- // ---------------------------------------------------------------------------
663
+ function createChatOutGate() {
664
+ let resolveOpened;
665
+ const opened = new Promise((resolve) => {
666
+ resolveOpened = resolve;
667
+ });
668
+ const gate = {
669
+ pending: new Set(),
670
+ open: false,
671
+ opened,
672
+ failOpen() {
673
+ if (gate.open)
674
+ return;
675
+ gate.open = true;
676
+ resolveOpened();
677
+ },
678
+ };
679
+ return gate;
680
+ }
681
+ const chatOutGateKey = locals.create("chat.outGate");
682
+ /**
683
+ * How long a write waits on the gate before giving up. A storage that hangs
684
+ * degrades to an ungated write rather than stalling the conversation.
685
+ * @internal
686
+ */
687
+ const CHAT_OUT_GATE_TIMEOUT_MS = 10_000;
828
688
  /**
829
- * Typed chat output stream `.writer()`, `.pipe()`, `.append()`, and
830
- * `.read()` methods pre-bound to this run's Session `.out` channel and
831
- * typed to `UIMessageChunk`.
689
+ * The ids to save non-final after a failed turn: the stream's partial answer,
690
+ * but only while the message under its id is still that partial by content.
691
+ * `onTurnComplete` may hand back a cloned history (same content, new objects),
692
+ * which keeps it partial, or replace it in place, which finishes it.
693
+ * @internal
694
+ */
695
+ function partialStillUnfinished(partial, fingerprint, messages) {
696
+ if (!partial || fingerprint === undefined)
697
+ return undefined;
698
+ const current = messages.find((message) => message.id === partial.id);
699
+ if (!current || fingerprintMessage(current) !== fingerprint)
700
+ return undefined;
701
+ return new Set([partial.id]);
702
+ }
703
+ async function awaitChatOutGate() {
704
+ const gate = locals.get(chatOutGateKey);
705
+ if (!gate || gate.open)
706
+ return;
707
+ const deadline = Date.now() + CHAT_OUT_GATE_TIMEOUT_MS;
708
+ while (gate.pending.size > 0) {
709
+ const remaining = deadline - Date.now();
710
+ if (remaining <= 0) {
711
+ gate.failOpen();
712
+ return;
713
+ }
714
+ const waitingOn = [...gate.pending];
715
+ let timedOut = false;
716
+ let timer;
717
+ try {
718
+ await Promise.race([
719
+ Promise.allSettled(waitingOn),
720
+ gate.opened,
721
+ new Promise((resolve) => {
722
+ timer = setTimeout(() => {
723
+ timedOut = true;
724
+ resolve();
725
+ }, remaining);
726
+ }),
727
+ ]);
728
+ }
729
+ finally {
730
+ if (timer)
731
+ clearTimeout(timer);
732
+ }
733
+ if (gate.open)
734
+ return;
735
+ if (timedOut) {
736
+ gate.failOpen();
737
+ return;
738
+ }
739
+ for (const settled of waitingOn)
740
+ gate.pending.delete(settled);
741
+ }
742
+ }
743
+ /**
744
+ * Register work that must land before anything from this turn reaches the
745
+ * frontend.
746
+ *
747
+ * Like {@link chatDefer} the work starts immediately and is never awaited by
748
+ * the hook that registered it, so it runs alongside the model and costs no
749
+ * time to first token. Unlike `chat.defer`, the output stream waits for it:
750
+ * no chunk of the answer is written to the session until it settles. That
751
+ * makes it the right home for a write the next page load has to see (a
752
+ * conversation row, a message insert), because a reader that can see the
753
+ * answer can also see what the write persisted.
754
+ *
755
+ * Reach for `chat.defer` instead when the timing does not matter for a
756
+ * reload: analytics, audit logs, search-index updates.
757
+ *
758
+ * This is not a consistency barrier for the turn. The work is still in flight
759
+ * while the model runs, so a tool, a `prepareStep`, or anything else executing
760
+ * during the turn can still read the state as it was before the write. It
761
+ * orders the write against what the frontend can see, nothing more. When the
762
+ * turn's own code has to read the write back, `await` it instead and accept
763
+ * the cost.
764
+ *
765
+ * A write registered here that fails, or outlasts the internal timeout, lets
766
+ * the stream through rather than stalling the conversation.
832
767
  *
833
- * Use from within a `chat.agent` run to write custom chunks:
768
+ * @example
834
769
  * ```ts
835
- * const { waitUntilComplete } = chat.stream.writer({
836
- * execute: ({ write }) => {
837
- * write({ type: "text-start", id: "status-1" });
838
- * write({ type: "text-delta", id: "status-1", delta: "Processing..." });
839
- * write({ type: "text-end", id: "status-1" });
840
- * },
841
- * });
842
- * await waitUntilComplete();
770
+ * onTurnStart: async ({ chatId, uiMessages }) => {
771
+ * chat.deferBeforeOutput(
772
+ * db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
773
+ * );
774
+ * },
843
775
  * ```
844
- *
845
- * Backed by the Session primitive so a chat's output outlives any single
846
- * run — subscribers (browser transport, server-side `ChatStream`) read
847
- * the session's `.out`, not a per-run stream. Run-scoped `target`
848
- * options on `.pipe()` are honoured as no-ops; the session is the target.
849
776
  */
777
+ function chatDeferBeforeOutput(promiseOrFn) {
778
+ const gate = locals.get(chatOutGateKey);
779
+ const work = typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn;
780
+ if (!gate || gate.open)
781
+ return;
782
+ gate.pending.add(work);
783
+ }
784
+ function gateWriterOptions(options) {
785
+ return {
786
+ ...options,
787
+ execute: async (api) => {
788
+ await awaitChatOutGate();
789
+ return await options.execute(api);
790
+ },
791
+ };
792
+ }
793
+ function gateOutStream(value) {
794
+ return (async function* () {
795
+ await awaitChatOutGate();
796
+ if (isReadableStream(value)) {
797
+ const reader = value.getReader();
798
+ try {
799
+ while (true) {
800
+ const { done, value: chunk } = await reader.read();
801
+ if (done)
802
+ break;
803
+ yield chunk;
804
+ }
805
+ }
806
+ finally {
807
+ reader.releaseLock();
808
+ }
809
+ }
810
+ else {
811
+ yield* value;
812
+ }
813
+ })();
814
+ }
850
815
  const chatStream = {
851
816
  // Stable opaque label for the run-scoped `RealtimeDefinedStream` shape.
852
817
  // `chatStream` is backed by the Session's `.out` channel — this id is
@@ -856,7 +821,7 @@ const chatStream = {
856
821
  id: "chat",
857
822
  pipe(value, options) {
858
823
  const { target: _target, ...sessionOptions } = (options ?? {});
859
- return getChatSession().out.pipe(value, sessionOptions);
824
+ return getChatSession().out.pipe(gateOutStream(value), sessionOptions);
860
825
  },
861
826
  async read(_runId, options) {
862
827
  // Session channels don't need a runId — the session is the address.
@@ -866,10 +831,11 @@ const chatStream = {
866
831
  },
867
832
  async append(value, options) {
868
833
  const { target: _target, ...sessionOptions } = (options ?? {});
834
+ await awaitChatOutGate();
869
835
  return getChatSession().out.append(value, sessionOptions);
870
836
  },
871
837
  writer(options) {
872
- return getChatSession().out.writer(options);
838
+ return getChatSession().out.writer(gateWriterOptions(options));
873
839
  },
874
840
  };
875
841
  // ---------------------------------------------------------------------------
@@ -919,9 +885,13 @@ function createLazyChatWriter() {
919
885
  let mergeImpl = null;
920
886
  let waitPromise = null;
921
887
  let resolveExecute = null;
888
+ let started = false;
889
+ const bufferedParts = [];
890
+ const bufferedStreams = [];
922
891
  function ensureInitialized() {
923
- if (writeImpl)
892
+ if (started)
924
893
  return;
894
+ started = true;
925
895
  const executePromise = new Promise((resolve) => {
926
896
  resolveExecute = resolve;
927
897
  });
@@ -931,7 +901,11 @@ function createLazyChatWriter() {
931
901
  execute: ({ write, merge }) => {
932
902
  writeImpl = write;
933
903
  mergeImpl = merge;
934
- return executePromise; // Keep execute alive until flush()
904
+ for (const part of bufferedParts.splice(0))
905
+ write(part);
906
+ for (const stream of bufferedStreams.splice(0))
907
+ merge(stream);
908
+ return executePromise;
935
909
  },
936
910
  });
937
911
  waitPromise = waitUntilComplete;
@@ -941,11 +915,17 @@ function createLazyChatWriter() {
941
915
  write(part) {
942
916
  ensureInitialized();
943
917
  queueResponsePart(part);
944
- writeImpl(part);
918
+ if (writeImpl)
919
+ writeImpl(part);
920
+ else
921
+ bufferedParts.push(part);
945
922
  },
946
923
  merge(stream) {
947
924
  ensureInitialized();
948
- mergeImpl(stream);
925
+ if (mergeImpl)
926
+ mergeImpl(stream);
927
+ else
928
+ bufferedStreams.push(stream);
949
929
  },
950
930
  },
951
931
  async flush() {
@@ -1027,7 +1007,7 @@ async function writeChatCustomAgentClientDataErrorToStream(payload) {
1027
1007
  }
1028
1008
  }
1029
1009
  async function reportChatCustomAgentClientDataError(payload, error, options) {
1030
- const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
1010
+ const errorText = error instanceof Error && error.message ? error.message : "An unexpected error occurred";
1031
1011
  logger.warn("chat.customAgent: clientData validation failed", {
1032
1012
  chatId: payload.chatId,
1033
1013
  trigger: payload.trigger,
@@ -1756,12 +1736,19 @@ async function installChatInputRouter(chatId, options) {
1756
1736
  }
1757
1737
  const router = entry.router;
1758
1738
  router.restore(checkpoint);
1739
+ if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) {
1740
+ router.markRecovered(options.recoveredSeqNums);
1741
+ }
1759
1742
  const floor = router.resumeFrom();
1760
1743
  if (floor !== undefined) {
1761
1744
  sessionStreams.setLastSeqNum(chatId, "in", floor);
1762
1745
  sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1763
1746
  }
1764
1747
  sessionStreams.onRecord(chatId, "in", (record) => {
1748
+ // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1749
+ // re-deliver below it and a replayable route would re-queue it.
1750
+ if (floor !== undefined && record.seqNum <= floor)
1751
+ return true;
1765
1752
  router.ingest(record);
1766
1753
  return true;
1767
1754
  });
@@ -1933,6 +1920,35 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1933
1920
  * @internal
1934
1921
  */
1935
1922
  const chatBackgroundQueueKey = locals.create("chat.backgroundQueue");
1923
+ /**
1924
+ * Background injections a step-boundary drain handed to the model this turn,
1925
+ * with the transcript message they followed. Reconciled into the model lane
1926
+ * and the persisted injections once the turn's response is in.
1927
+ */
1928
+ const chatPendingBackgroundKey = locals.create("chat.pendingBackground");
1929
+ /**
1930
+ * System-role context injected mid-conversation, held for the instructions lane.
1931
+ *
1932
+ * Kept apart from the message queue because ai@7 rejects a system message inside
1933
+ * `messages` for every provider — `standardizePrompt` throws upstream of any
1934
+ * provider call, and its own advice is to use the instructions option. Instructions
1935
+ * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1936
+ * home: appended as another system block rather than smuggled into the transcript.
1937
+ *
1938
+ * This is also the only way to inject *trusted* context. A message injected as
1939
+ * `user` is untrusted by construction, and a well-aligned model treats it that
1940
+ * way — it will say so, and re-derive the answer from tools instead.
1941
+ */
1942
+ const chatInjectedInstructionsKey = locals.create("chat.injectedInstructions");
1943
+ /**
1944
+ * What a turn already consumed from the instructions lane, so a second
1945
+ * `toStreamTextOptions()` call in the same turn sees the same blocks.
1946
+ *
1947
+ * Consumed blocks are moved here rather than left in the pending lane: leaving
1948
+ * them there means an injection made during the consumed turn sits behind them,
1949
+ * and clearing the lane on the next turn destroys both.
1950
+ */
1951
+ const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsConsumed");
1936
1952
  /**
1937
1953
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1938
1954
  * same worker don't share state.
@@ -2443,12 +2459,31 @@ const chatToolsOptionKey = locals.create("chat.toolsOption");
2443
2459
  const chatResolvedToolsKey = locals.create("chat.resolvedTools");
2444
2460
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2445
2461
  const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested");
2462
+ /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2463
+ const chatUpgradeExternalDeploymentIdKey = locals.create("chat.upgradeExternalDeploymentId");
2446
2464
  /**
2447
2465
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2448
2466
  * turn completes, without any upgrade semantics. Checked at the same
2449
2467
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2450
2468
  */
2451
2469
  const chatEndRunRequestedKey = locals.create("chat.endRunRequested");
2470
+ /**
2471
+ * @internal Set by `chat.close()`. Holds the close request (and its reason)
2472
+ * for the rest of the run: the loop writes the terminal `session-closed`
2473
+ * record, closes the session row, and exits at the same post-turn /
2474
+ * pre-wait sites as `chatEndRunRequestedKey`.
2475
+ */
2476
+ const chatCloseRequestedKey = locals.create("chat.closeRequested");
2477
+ /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2478
+ const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2479
+ /** @internal Set once the session row is closed, so the close happens once. */
2480
+ const chatClosePerformedKey = locals.create("chat.closePerformed");
2481
+ /**
2482
+ * @internal Set once the terminal `.out` record is written. Tracked apart from
2483
+ * {@link chatClosePerformedKey} so a retried close does not emit a second
2484
+ * client-visible event.
2485
+ */
2486
+ const chatCloseRecordWrittenKey = locals.create("chat.closeRecordWritten");
2452
2487
  /** @internal */
2453
2488
  const chatAgentCompactionKey = locals.create("chat.agentCompaction");
2454
2489
  /**
@@ -2465,6 +2500,32 @@ export { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage };
2465
2500
  const chatPendingMessagesKey = locals.create("chat.pendingMessages");
2466
2501
  /** @internal */
2467
2502
  const chatSteeringQueueKey = locals.create("chat.steeringQueue");
2503
+ /**
2504
+ * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2505
+ *
2506
+ * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2507
+ * has to append the messages it injects. Without that, an injected message
2508
+ * reaches the model and the browser but no hook, so an app persisting from
2509
+ * `onTurnComplete` never learns it existed.
2510
+ */
2511
+ const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages");
2512
+ /**
2513
+ * Steering messages a drain consumed that the model accumulator has not been
2514
+ * given yet.
2515
+ *
2516
+ * The two accumulators are maintained separately, and the model one is
2517
+ * normally advanced by appending each turn's delta. A drained message is
2518
+ * appended to the UI one but reaches the model only through the `prepareStep`
2519
+ * return value, which is per-step: without this the model lane never learns
2520
+ * the message exists and every later turn of the run answers without it,
2521
+ * while the browser, the snapshot and `chat.history.*` all still show it.
2522
+ *
2523
+ * Held as the messages rather than a "rebuild me" flag because the model lane
2524
+ * can only be appended to, never reconstructed. Compaction replaces it with a
2525
+ * summary and deliberately leaves the UI lane whole, so reconverting the UI
2526
+ * lane restores every message the summary replaced.
2527
+ */
2528
+ const chatPendingSteerKey = locals.create("chat.pendingSteer");
2468
2529
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2469
2530
  const chatInjectedMessageIdsKey = locals.create("chat.injectedMessageIds");
2470
2531
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2817,20 +2878,32 @@ function chatCompactionStep(options) {
2817
2878
  return result.type === "skipped" ? undefined : result;
2818
2879
  };
2819
2880
  }
2820
- // ---------------------------------------------------------------------------
2821
- // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2822
- // ---------------------------------------------------------------------------
2881
+ const EMPTY_DRAIN = { injected: [], claimed: [] };
2882
+ /**
2883
+ * The model messages to record for one claimed message. Without `prepare`
2884
+ * each entry's own conversion is used. With it, `prepare` returned one list
2885
+ * for the whole batch, so the first claimed message carries all of it and the
2886
+ * rest carry none, which keeps the total exactly what the model received.
2887
+ */
2888
+ function modelFormOf(m, batch, injected) {
2889
+ return batch[0] === m ? injected : [];
2890
+ }
2823
2891
  /**
2824
2892
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2825
2893
  * pending messages. If it returns true, calls `prepareMessages` once to
2826
2894
  * transform the batch, then clears the queue.
2827
- * Returns the model messages to inject (empty if none).
2895
+ * Returns the model messages to inject and the UI messages actually claimed.
2896
+ *
2897
+ * `claimed` is returned rather than only published to locals because each
2898
+ * surface files it somewhere different: `chat.agent` has an accumulator in
2899
+ * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2900
+ * is silently a no-op for any surface that never set the key.
2828
2901
  * @internal
2829
2902
  */
2830
2903
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2831
2904
  const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
2832
2905
  if (!queue || queue.length === 0)
2833
- return [];
2906
+ return EMPTY_DRAIN;
2834
2907
  const ctx = locals.get(chatTurnContextKey);
2835
2908
  const stepNumber = steps.length - 1;
2836
2909
  /**
@@ -2853,7 +2926,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2853
2926
  // Call shouldInject once for the whole batch
2854
2927
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2855
2928
  if (!shouldInject)
2856
- return [];
2929
+ return EMPTY_DRAIN;
2857
2930
  const textOfUIMessage = (m) => (m.parts ?? [])
2858
2931
  .filter((p) => p.type === "text")
2859
2932
  .map((p) => p.text)
@@ -2899,7 +2972,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2899
2972
  queue.splice(at, 1);
2900
2973
  }
2901
2974
  if (claimed.length === 0)
2902
- return [];
2975
+ return EMPTY_DRAIN;
2903
2976
  /**
2904
2977
  * Give the claim back if the transform fails. `prepare` is caller code and
2905
2978
  * can throw; the records have already left the router by this point, so
@@ -2929,6 +3002,37 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2929
3002
  for (const m of claimedUIMessages)
2930
3003
  injectedIds.add(m.id);
2931
3004
  }
3005
+ // Record them as part of the conversation.
3006
+ //
3007
+ // The model has them and the browser has them; without this the
3008
+ // accumulator does not, so they reach neither `uiMessages` nor
3009
+ // `newUIMessages` on `onTurnComplete` and an app that persists from there
3010
+ // silently loses the instruction the answer was shaped by. Appending here
3011
+ // rather than at turn end keeps them in the order they happened: after the
3012
+ // message that started the turn, before the response that answers it.
3013
+ //
3014
+ // De-duplicated by id because a step boundary can drain more than once per
3015
+ // turn, and because a message that failed to inject falls back to becoming
3016
+ // its own turn, where it is accumulated the normal way.
3017
+ const currentUIMessages = locals.get(chatCurrentUIMessagesKey);
3018
+ const turnNew = locals.get(chatTurnNewUIMessagesKey);
3019
+ for (const m of claimedUIMessages) {
3020
+ if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
3021
+ currentUIMessages.push(m);
3022
+ }
3023
+ if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
3024
+ turnNew.push(m);
3025
+ }
3026
+ }
3027
+ if (claimedUIMessages.length > 0 && currentUIMessages) {
3028
+ const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
3029
+ for (const m of claimedUIMessages) {
3030
+ if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
3031
+ pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
3032
+ }
3033
+ }
3034
+ locals.set(chatPendingSteerKey, pendingSteer);
3035
+ }
2932
3036
  // Write injection confirmation chunk to the stream so the frontend
2933
3037
  // knows which messages were injected and where in the response.
2934
3038
  if (injected.length > 0) {
@@ -2970,7 +3074,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2970
3074
  /* non-fatal */
2971
3075
  }
2972
3076
  }
2973
- return injected;
3077
+ return { injected, claimed: claimedUIMessages };
2974
3078
  }, {
2975
3079
  attributes: {
2976
3080
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3196,32 +3300,149 @@ export function buildSkillTools(skills) {
3196
3300
  return { loadSkill, readFile, bash };
3197
3301
  }
3198
3302
  /**
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`.
3303
+ * A `streamText` with the agent's managed options already applied.
3304
+ *
3305
+ * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3306
+ * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3307
+ * because forgetting the spread silently drops the managed prompt, the skill
3308
+ * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3309
+ * conversational injection.
3310
+ *
3311
+ * Caller options win for everything the caller owns (model, messages, signal,
3312
+ * stopWhen). The three that would otherwise clobber managed behaviour are
3313
+ * merged instead of replaced:
3314
+ *
3315
+ * - `tools` are passed into the helper, so skill tools survive.
3316
+ * - `prepareStep` is composed after the managed one, so a caller's per-step
3317
+ * overrides apply on top of steering and compaction instead of disabling them.
3318
+ *
3319
+ * `system` may be set at the call site, on `chat.agent({ system })`, or
3320
+ * through `chat.prompt.set()`, but only in one of them. Setting it in two
3321
+ * places throws: no shape merges two system values on every supported
3322
+ * version, and dropping one silently is the failure this seam exists to
3323
+ * prevent. Injected instructions append to whichever one is in play.
3324
+ */
3325
+ /**
3326
+ * The agent-level managed options (`registry`, `system`, `cacheControl`,
3327
+ * `systemProviderOptions`), published for the run so that
3328
+ * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3329
+ * `streamText` saw them, and the documented spread form silently ran without
3330
+ * the agent's system prompt or model.
3331
+ */
3332
+ const chatAgentManagedConfigKey = locals.create("chat.agentManagedConfig");
3333
+ /**
3334
+ * The caller's `streamText` options merged with the agent's managed ones.
3206
3335
  *
3207
- * If no prompt has been set, returns `{}` (no-op spread).
3336
+ * Pure, and separate from the call so it can be asserted directly: everything
3337
+ * the caller did not name has to survive the merge, and the way to be sure of
3338
+ * that is to look at the merged object rather than at what the model received.
3208
3339
  */
3340
+ function buildManagedStreamTextOptions(options, config) {
3341
+ const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3342
+ /**
3343
+ * Only the three keys that collide are intercepted. Everything else, telemetry
3344
+ * included, stays in `rest` and reaches `streamText` untouched, with the
3345
+ * caller's value winning because `rest` is spread after `managed`. Pulling a
3346
+ * key out to "handle" it is how a caller's option gets silently dropped.
3347
+ */
3348
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3349
+ const managed = toStreamTextOptions({
3350
+ registry,
3351
+ system: callerSystem ?? agentSystem,
3352
+ cacheControl,
3353
+ systemProviderOptions,
3354
+ /**
3355
+ * A call site that names `tools` replaces the agent's set rather than
3356
+ * adding to it, so narrowing the tools for one call still works. Omitting
3357
+ * `tools` falls back to the agent's, which is what an `onAction`
3358
+ * regenerate needs: without it a regenerated answer can call nothing.
3359
+ */
3360
+ tools: (tools ?? agentTools),
3361
+ });
3362
+ const promptSystem = locals.get(chatPromptKey)?.text;
3363
+ /**
3364
+ * Two managed sources conflict too, not only a caller against a managed one.
3365
+ * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3366
+ * would win and `chat.agent({ system })` would go nowhere.
3367
+ */
3368
+ if (promptSystem && agentSystem) {
3369
+ throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3370
+ "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3371
+ "place, and add per-turn context with chat.inject({ role: 'system' }).");
3372
+ }
3373
+ const managedSystem = promptSystem || agentSystem;
3374
+ if (callerSystem !== undefined && managedSystem) {
3375
+ throw new Error("chat.agent: `system` is already set " +
3376
+ (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3377
+ ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3378
+ "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3379
+ }
3380
+ const managedPrepareStep = managed.prepareStep;
3381
+ if (typeof callerPrepareStep === "function") {
3382
+ managed.prepareStep = async (arg) => {
3383
+ const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3384
+ const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3385
+ return { ...(first ?? {}), ...(second ?? {}) };
3386
+ };
3387
+ }
3388
+ return { ...managed, ...rest };
3389
+ }
3390
+ /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3391
+ export const __buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3392
+ function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3393
+ const bound = (options = {}) => aiStreamText(buildManagedStreamTextOptions(options, {
3394
+ registry,
3395
+ system: agentSystem,
3396
+ cacheControl: agentCacheControl,
3397
+ systemProviderOptions: agentSystemProviderOptions,
3398
+ /** Read per call, so per-turn tools resolved after binding are included. */
3399
+ tools: locals.get(chatResolvedToolsKey),
3400
+ }));
3401
+ return bound;
3402
+ }
3209
3403
  function toStreamTextOptions(options) {
3404
+ const agentDefaults = locals.get(chatAgentManagedConfigKey);
3405
+ if (agentDefaults) {
3406
+ options = {
3407
+ registry: agentDefaults.registry,
3408
+ system: agentDefaults.system,
3409
+ cacheControl: agentDefaults.cacheControl,
3410
+ systemProviderOptions: agentDefaults.systemProviderOptions,
3411
+ ...options,
3412
+ };
3413
+ }
3210
3414
  const prompt = locals.get(chatPromptKey);
3211
3415
  const skills = locals.get(chatSkillsKey);
3212
3416
  const result = {};
3213
3417
  // Build the combined system prompt: stored prompt + skills preamble.
3214
- const promptText = prompt?.text ?? "";
3418
+ const baseSystem = options?.system;
3419
+ const baseSystemText = typeof baseSystem === "string"
3420
+ ? baseSystem
3421
+ : typeof baseSystem?.content === "string"
3422
+ ? baseSystem.content
3423
+ : "";
3424
+ const promptText = prompt?.text || baseSystemText;
3215
3425
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3216
3426
  if (promptText || skillsText) {
3217
3427
  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()`.
3428
+ /**
3429
+ * Resolve system-prompt provider options for caching. Precedence, most
3430
+ * specific first and no deep merge: explicit `systemProviderOptions`, the
3431
+ * `cacheControl` sugar, the ones carried on a structured `system` message,
3432
+ * then whatever `chat.prompt.set()` stored.
3433
+ *
3434
+ * A structured `system` counts only when its own text is the one being
3435
+ * sent. When `chat.prompt.set()` supplied the text, its provider options
3436
+ * are the ones that describe it.
3437
+ */
3438
+ const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3439
+ ? baseSystem.providerOptions
3440
+ : undefined;
3221
3441
  const systemProviderOptions = options?.systemProviderOptions ??
3222
3442
  (options?.cacheControl
3223
3443
  ? { anthropic: { cacheControl: options.cacheControl } }
3224
3444
  : undefined) ??
3445
+ baseSystemProviderOptions ??
3225
3446
  locals.get(chatPromptProviderOptionsKey);
3226
3447
  // A bare string stays a bare string (the unchanged default). With provider
3227
3448
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3230,6 +3451,88 @@ function toStreamTextOptions(options) {
3230
3451
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3231
3452
  : systemText;
3232
3453
  }
3454
+ /**
3455
+ * Append anything injected as system context, in whichever shape the installed
3456
+ * AI SDK accepts.
3457
+ *
3458
+ * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3459
+ * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3460
+ * range still spans all three, so emitting an array unconditionally would break
3461
+ * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3462
+ * a system message inside `messages` that v7 rejects.
3463
+ *
3464
+ * So: concatenate into one string when the base is a plain string, which every
3465
+ * version accepts and which loses nothing (separate blocks only matter for
3466
+ * per-block `providerOptions`). Use the array form only when the base is already
3467
+ * a structured message — that path requires v6+ regardless, because it is how
3468
+ * prompt caching marks the system block, and flattening it would silently throw
3469
+ * the cache away.
3470
+ *
3471
+ * Either way the injected text goes last: the base prompt keeps its position for
3472
+ * caching, and the addition reads as a later amendment. A changed prefix does
3473
+ * cost the first call its cache hit, on turns that actually injected.
3474
+ */
3475
+ /**
3476
+ * Consumed once per turn, not once per read, and moved out of the lane rather
3477
+ * than marked read in place.
3478
+ *
3479
+ * Per turn, because a `run()` that builds options twice (a cheap classifier
3480
+ * pass and then the answer) has to see the injection in both, and draining on
3481
+ * read hands it to whichever call ran first. Moved out, because blocks left in
3482
+ * the lane sit in front of anything injected during the same turn, and
3483
+ * clearing the lane on the next turn then destroys both. Outside a turn there
3484
+ * is no turn to scope the stash to, so the lane drains on read there.
3485
+ */
3486
+ const injectedInstructions = locals.get(chatInjectedInstructionsKey);
3487
+ const currentTurn = locals.get(chatTurnContextKey)?.turn;
3488
+ const consumedThisTurn = currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
3489
+ let injectedBlocks = [];
3490
+ if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3491
+ injectedBlocks = consumedThisTurn.blocks;
3492
+ // Anything injected since the stash was taken joins it, so an instruction
3493
+ // added after an action read the lane still reaches the real turn that
3494
+ // shares the action's turn number, rather than the one after.
3495
+ if (injectedInstructions && injectedInstructions.length > 0) {
3496
+ injectedBlocks.push(...injectedInstructions.splice(0));
3497
+ }
3498
+ }
3499
+ else if (injectedInstructions && injectedInstructions.length > 0) {
3500
+ injectedBlocks = injectedInstructions.splice(0);
3501
+ if (currentTurn !== undefined) {
3502
+ locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3503
+ }
3504
+ }
3505
+ if (injectedBlocks.length > 0) {
3506
+ const blocks = injectedBlocks;
3507
+ const injectedText = blocks
3508
+ .map((block) => (typeof block.content === "string" ? block.content : ""))
3509
+ .filter(Boolean)
3510
+ .join("\n\n");
3511
+ const base = result.system;
3512
+ if (base === undefined) {
3513
+ result.system = injectedText;
3514
+ }
3515
+ else if (typeof base === "string") {
3516
+ result.system = [base, injectedText].filter(Boolean).join("\n\n");
3517
+ }
3518
+ else {
3519
+ // Merged into the existing block rather than added as a second one. An array
3520
+ // of system blocks would keep the base block's cache entry, but ai@5 rejects
3521
+ // it outright ("Invalid prompt: system must be a string") while accepting a
3522
+ // single structured block, and this package's peer range still spans v5.
3523
+ // Choosing per version would mean resolving the installed version at runtime,
3524
+ // which is not something to build on: `import.meta.url` is illegal in this
3525
+ // package's CommonJS output, and a bundled task may have no resolvable `ai`
3526
+ // to read. One shape that works everywhere beats a cache hit.
3527
+ const baseBlock = base;
3528
+ result.system = {
3529
+ ...baseBlock,
3530
+ content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3531
+ .filter(Boolean)
3532
+ .join("\n\n"),
3533
+ };
3534
+ }
3535
+ }
3233
3536
  // Prompt-related options (only if chat.prompt.set() was called)
3234
3537
  if (prompt) {
3235
3538
  // Resolve model via registry if both are present
@@ -3284,7 +3587,7 @@ function toStreamTextOptions(options) {
3284
3587
  }
3285
3588
  // 2. Pending message injection (steering)
3286
3589
  if (taskPendingMessages) {
3287
- const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3590
+ const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3288
3591
  if (injected.length > 0) {
3289
3592
  resultMessages = [...(resultMessages ?? messages), ...injected];
3290
3593
  }
@@ -3294,18 +3597,96 @@ function toStreamTextOptions(options) {
3294
3597
  if (bgQueue && bgQueue.length > 0) {
3295
3598
  const injected = bgQueue.splice(0); // drain
3296
3599
  resultMessages = [...(resultMessages ?? messages), ...injected];
3600
+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
3601
+ pendingBackground.push({
3602
+ afterId: locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
3603
+ messages: injected,
3604
+ });
3605
+ locals.set(chatPendingBackgroundKey, pendingBackground);
3297
3606
  }
3298
3607
  return resultMessages ? { messages: resultMessages } : undefined;
3299
3608
  };
3300
3609
  }
3301
3610
  return result;
3302
3611
  }
3612
+ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3613
+ /**
3614
+ * Turn the current action into a turn.
3615
+ *
3616
+ * Return it from `onAction` after editing history. The action's own work is
3617
+ * finished first (the edit is applied and snapshotted), then a turn runs on the
3618
+ * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3619
+ * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3620
+ * advances. That gives the answer everything a turn has, the system prompt,
3621
+ * tools, steering, compaction, injected instructions and persistence, with no
3622
+ * action-specific handling.
3623
+ *
3624
+ * @example
3625
+ * ```ts
3626
+ * onAction: async ({ action }) => {
3627
+ * if (action.type === "regenerate") {
3628
+ * chat.history.slice(0, -1);
3629
+ * return chat.turn();
3630
+ * }
3631
+ * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3632
+ * },
3633
+ * ```
3634
+ */
3635
+ function chatTurn() {
3636
+ return { [actionTurnBrand]: true };
3637
+ }
3638
+ function isActionTurn(value) {
3639
+ return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3640
+ }
3641
+ /**
3642
+ * Replace, in a model lane, the run of messages one UI message contributed.
3643
+ *
3644
+ * Used when a UI message is replaced in place (a tool-approval continuation
3645
+ * merging onto the trailing assistant, a captured response reusing an existing
3646
+ * id, a partial replacing an existing message). Reconverting the whole lane
3647
+ * from the UI lane would also replace a compaction summary with the full
3648
+ * transcript and drop the model forms `pendingMessages.prepare` produced.
3649
+ *
3650
+ * The replaced message is the trailing one, so its run is the lane's tail,
3651
+ * before any steer forms appended after it this turn (`tailAfter`). If the
3652
+ * tail does not match the old message's conversion, nothing is changed and
3653
+ * `false` is returned so the caller can fall back to a full reconversion.
3654
+ */
3655
+ async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3656
+ const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3657
+ const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3658
+ // A message that converts to nothing (a pending tool call with no output yet,
3659
+ // which `ignoreIncompleteToolCalls` drops) locates no run in the lane. Matching
3660
+ // an empty slice would splice the new run in without removing what the message
3661
+ // actually contributed, such as a spliced head-start partial, and the lane would
3662
+ // then carry the same tool call twice.
3663
+ if (oldRun.length === 0)
3664
+ return false;
3665
+ const end = lane.length - tailAfter;
3666
+ const start = end - oldRun.length;
3667
+ if (start < 0 || end > lane.length)
3668
+ return false;
3669
+ if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3670
+ return false;
3671
+ lane.splice(start, oldRun.length, ...newRun);
3672
+ return true;
3673
+ }
3303
3674
  function isUIMessageStreamable(value) {
3304
3675
  return (typeof value === "object" &&
3305
3676
  value !== null &&
3306
3677
  "toUIMessageStream" in value &&
3307
3678
  typeof value.toUIMessageStream === "function");
3308
3679
  }
3680
+ const warnedHydrateMessagesDeprecated = new Set();
3681
+ function warnHydrateMessagesDeprecatedOnce(agentId) {
3682
+ if (warnedHydrateMessagesDeprecated.has(agentId))
3683
+ return;
3684
+ warnedHydrateMessagesDeprecated.add(agentId);
3685
+ console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
3686
+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
3687
+ "lets the application own the model's context, with crash recovery and durable " +
3688
+ "compaction that `hydrateMessages` never had.");
3689
+ }
3309
3690
  let warnedMissingOnAction = false;
3310
3691
  function warnMissingOnActionOnce() {
3311
3692
  if (warnedMissingOnAction)
@@ -3438,10 +3819,22 @@ function chatCustomAgent(options) {
3438
3819
  await installChatInputRouter(payload.chatId, {
3439
3820
  resuming: Boolean(payload.continuation),
3440
3821
  });
3822
+ // A custom agent's loop is the customer's, so there is no exit site the
3823
+ // SDK controls. Perform a requested close when `run()` returns, whatever
3824
+ // shape the loop had. Idempotent, so the createSession iterator having
3825
+ // already closed on its own exit costs nothing.
3826
+ const withClose = async (result) => {
3827
+ try {
3828
+ return await result;
3829
+ }
3830
+ finally {
3831
+ await performChatClose();
3832
+ }
3833
+ };
3441
3834
  // Keep the schema-free path identical to the original custom-agent
3442
3835
  // wrapper, including when userRun starts executing.
3443
3836
  if (!parseClientData) {
3444
- return userRun(payload, runOptions);
3837
+ return withClose(userRun(payload, runOptions));
3445
3838
  }
3446
3839
  const isHandoverBoot = payload.trigger === "handover-prepare";
3447
3840
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3457,7 +3850,7 @@ function chatCustomAgent(options) {
3457
3850
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3458
3851
  });
3459
3852
  if (validated.ok) {
3460
- return userRun(validated.payload, runOptions);
3853
+ return withClose(userRun(validated.payload, runOptions));
3461
3854
  }
3462
3855
  if (isHandoverBoot) {
3463
3856
  const signal = await waitForHandover({
@@ -3493,7 +3886,7 @@ function chatCustomAgent(options) {
3493
3886
  sessionId: next.output.sessionId ?? payload.sessionId,
3494
3887
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3495
3888
  };
3496
- return userRun(recoveredPayload, runOptions);
3889
+ return withClose(userRun(recoveredPayload, runOptions));
3497
3890
  },
3498
3891
  });
3499
3892
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3506,7 +3899,19 @@ function chatCustomAgent(options) {
3506
3899
  return task;
3507
3900
  }
3508
3901
  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;
3902
+ 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;
3903
+ if (hydrateMessages) {
3904
+ if (storage) {
3905
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3906
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3907
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3908
+ }
3909
+ if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
3910
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3911
+ "`loadContext`. Both would own the model's context; keep one.");
3912
+ }
3913
+ warnHydrateMessagesDeprecatedOnce(options.id);
3914
+ }
3510
3915
  const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3511
3916
  const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
3512
3917
  // chat.agent does not expose generic retry options (see docstring on
@@ -3589,6 +3994,42 @@ function chatAgent(options) {
3589
3994
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3590
3995
  // registered) — the wire is delta-only now, no longer a seed.
3591
3996
  let accumulatedMessages = [];
3997
+ /**
3998
+ * Give the model accumulator the background injections a step-boundary
3999
+ * drain handed to the model this turn, and record them for persistence.
4000
+ * Returns how many model messages were appended.
4001
+ */
4002
+ const reconcilePendingBackground = () => {
4003
+ const pending = locals.get(chatPendingBackgroundKey);
4004
+ if (!pending || pending.length === 0)
4005
+ return 0;
4006
+ locals.set(chatPendingBackgroundKey, []);
4007
+ let appended = 0;
4008
+ for (const entry of pending) {
4009
+ accumulatedMessages.push(...entry.messages);
4010
+ laneInjections.push(entry);
4011
+ appended += entry.messages.length;
4012
+ }
4013
+ return appended;
4014
+ };
4015
+ /**
4016
+ * Give the model accumulator the steering messages a drain consumed,
4017
+ * in the form the model actually received. Appended, never reconverted
4018
+ * from the UI lane, so a model-only compaction summary survives. Called
4019
+ * on both the success and the error path, before the response or the
4020
+ * partial joins the lane, so the order stays steer-then-answer.
4021
+ */
4022
+ const reconcilePendingSteer = (options) => {
4023
+ const pending = locals.get(chatPendingSteerKey);
4024
+ if (!pending || pending.length === 0)
4025
+ return [];
4026
+ locals.set(chatPendingSteerKey, []);
4027
+ for (const entry of pending) {
4028
+ accumulatedMessages.push(...entry.model);
4029
+ options?.turnNew?.push(...entry.model);
4030
+ }
4031
+ return pending;
4032
+ };
3592
4033
  // Accumulated UI messages for persistence. Mirrors the model accumulator
3593
4034
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
3594
4035
  let accumulatedUIMessages = [];
@@ -3606,7 +4047,191 @@ function chatAgent(options) {
3606
4047
  // collectively cost ~600ms on every first-message TTFC. Both reads
3607
4048
  // swallow errors internally; the agent stays available either way.
3608
4049
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
4050
+ const transcriptStorage = storage ??
4051
+ transcriptStorageOverride ??
4052
+ defaultStorage;
4053
+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
4054
+ /**
4055
+ * Who supplies the model's context each turn: the deprecated
4056
+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
4057
+ * the runtime's own transcript.
4058
+ */
4059
+ const loadContextHook = hydrateMessages
4060
+ ? (event) => hydrateMessages(event)
4061
+ : storageLoadContext
4062
+ ? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
4063
+ : undefined;
4064
+ let transcriptShadow = createTranscriptShadow([]);
4065
+ let bootTranscriptState = null;
4066
+ /**
4067
+ * True while the model lane holds a compaction summary, so it cannot be
4068
+ * rebuilt from the transcript and has to be persisted as state. Reset
4069
+ * wherever the lane is reconverted from the UI lane.
4070
+ */
4071
+ let laneCompacted = false;
4072
+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
4073
+ let laneInjections = [];
4074
+ let persistedStateSet = false;
3609
4075
  let bootSnapshot;
4076
+ let bootClientData = payload.metadata;
4077
+ if (parseClientData) {
4078
+ try {
4079
+ bootClientData = await parseClientData(payload.metadata);
4080
+ }
4081
+ catch {
4082
+ bootClientData = payload.metadata;
4083
+ }
4084
+ }
4085
+ /**
4086
+ * The `lastOutEventId` the most recent snapshot carried.
4087
+ *
4088
+ * A snapshot written outside a turn — after an action mutates history — has
4089
+ * no turn cursor of its own, and writing `undefined` there would drop the
4090
+ * resume point and make the next boot replay from further back. Retaining it
4091
+ * keeps an action's write cursor-neutral.
4092
+ */
4093
+ let lastSnapshotOutEventId;
4094
+ /**
4095
+ * The `lastInEventId` the most recent snapshot carried.
4096
+ *
4097
+ * A turn-start save happens after the incoming message has been handed to
4098
+ * the turn loop, so the router's live resume floor has already advanced
4099
+ * past it. Persisting that floor before the turn runs would let the next
4100
+ * boot resume past a message this run never answered, which is exactly
4101
+ * what a deferred or recovered message depends on. Turn-start carries
4102
+ * this instead.
4103
+ */
4104
+ let lastSnapshotInEventId;
4105
+ const storageTrigger = (trigger) => trigger === "regenerate-message"
4106
+ ? "regenerate-message"
4107
+ : trigger === "action" || trigger === "action-turn"
4108
+ ? "action"
4109
+ : "submit-message";
4110
+ /**
4111
+ * Hand the runtime's view of the transcript to the storage as a
4112
+ * changeset: the diff against what was last saved, plus the cursors the
4113
+ * next boot resumes from. The shadow only advances when the save
4114
+ * succeeds, so a failed save is folded into the next changeset.
4115
+ */
4116
+ /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
4117
+ let transcriptState = null;
4118
+ let transcriptSaveChain = Promise.resolve();
4119
+ const runSaveTranscript = async (opts) => {
4120
+ const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
4121
+ nonFinalIds: opts.nonFinalIds,
4122
+ });
4123
+ const throughId = opts.messages.at(-1)?.id ?? "";
4124
+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
4125
+ const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
4126
+ ? {
4127
+ v: 1,
4128
+ ...(laneCompacted
4129
+ ? {
4130
+ compaction: {
4131
+ modelMessages: accumulatedMessages,
4132
+ throughId,
4133
+ },
4134
+ }
4135
+ : {}),
4136
+ ...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
4137
+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
4138
+ }
4139
+ : null;
4140
+ if (runtimeState !== null || persistedStateSet) {
4141
+ changes.push({ op: "state", value: runtimeState });
4142
+ }
4143
+ if (opts.skipIfUnchanged && changes.length === 0)
4144
+ return;
4145
+ transcriptState = runtimeState;
4146
+ const liveInCursor = chatInputRouter().resumeFloor();
4147
+ const inCursor = opts.carryInCursor
4148
+ ? lastSnapshotInEventId
4149
+ : liveInCursor !== undefined
4150
+ ? String(liveInCursor)
4151
+ : undefined;
4152
+ await transcriptStorage.save({
4153
+ chatId: payload.chatId,
4154
+ clientData: opts.clientData,
4155
+ turn: opts.turn,
4156
+ trigger: opts.trigger,
4157
+ runId: ctx.run.id,
4158
+ ctx,
4159
+ }, {
4160
+ reason: opts.reason,
4161
+ changes,
4162
+ transcript: {
4163
+ entries: opts.messages.map((message) => ({
4164
+ id: message.id,
4165
+ final: !shadow.nonFinal.has(message.id),
4166
+ message,
4167
+ })),
4168
+ state: transcriptState,
4169
+ },
4170
+ cursors: {
4171
+ lastOutEventId: opts.lastOutEventId,
4172
+ lastInEventId: inCursor,
4173
+ },
4174
+ });
4175
+ transcriptShadow = shadow;
4176
+ lastSnapshotInEventId = inCursor;
4177
+ persistedStateSet = runtimeState !== null;
4178
+ };
4179
+ /**
4180
+ * Serialise every save onto one chain. `runSaveTranscript` derives its
4181
+ * changeset from `transcriptShadow` and only advances it once the write
4182
+ * lands, so two overlapping saves would diff against stale state. The
4183
+ * message list is copied on the way in because the accumulator keeps
4184
+ * mutating while a queued save waits its turn. A rejection is handed to
4185
+ * the caller but never poisons the chain.
4186
+ */
4187
+ const saveTranscript = (opts) => {
4188
+ const queued = { ...opts, messages: [...opts.messages] };
4189
+ const run = () => runSaveTranscript(queued);
4190
+ const next = transcriptSaveChain.then(run, run);
4191
+ transcriptSaveChain = next.then(() => undefined, () => undefined);
4192
+ return next;
4193
+ };
4194
+ /**
4195
+ * Persist the accumulator outside a turn.
4196
+ *
4197
+ * An action is not a turn, so it never reaches the turn-complete path where
4198
+ * the transcript is normally saved, but a `chat.history` mutation changes
4199
+ * the conversation and has to survive the run ending.
4200
+ *
4201
+ * Cursor-neutral: an action has no turn cursor of its own, and writing
4202
+ * `undefined` would drop the resume point the last turn established and make
4203
+ * the next boot replay from further back.
4204
+ */
4205
+ const writeSnapshotOutsideTurn = async (reason, turnContext) => {
4206
+ if (hydrateMessages)
4207
+ return;
4208
+ try {
4209
+ await tracer.startActiveSpan("snapshot.write", async () => {
4210
+ await saveTranscript({
4211
+ reason: "action",
4212
+ messages: accumulatedUIMessages,
4213
+ turn: turnContext.turn,
4214
+ trigger: "action",
4215
+ clientData: turnContext.clientData,
4216
+ lastOutEventId: lastSnapshotOutEventId,
4217
+ });
4218
+ }, {
4219
+ attributes: {
4220
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4221
+ [SemanticInternalAttributes.COLLAPSED]: true,
4222
+ "chat.snapshot.reason": reason,
4223
+ "chat.messages.count": accumulatedUIMessages.length,
4224
+ },
4225
+ });
4226
+ }
4227
+ catch (error) {
4228
+ logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4229
+ error: error instanceof Error ? error.message : String(error),
4230
+ sessionId: sessionIdForSnapshot,
4231
+ reason,
4232
+ });
4233
+ }
4234
+ };
3610
4235
  let replayedSettled = [];
3611
4236
  let replayedPartial;
3612
4237
  let replayedPartialRaw;
@@ -3616,13 +4241,20 @@ function chatAgent(options) {
3616
4241
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
3617
4242
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
3618
4243
  const bootInjectedQueue = [];
4244
+ const recoveredSeqByPayload = new WeakMap();
4245
+ const dispatchBootInjected = () => bootInjectedQueue.shift();
4246
+ const settleRecoveredTurn = (wirePayload) => {
4247
+ const settledSeq = recoveredSeqByPayload.get(wirePayload);
4248
+ if (settledSeq !== undefined)
4249
+ chatInputRouter().settleRecovered(settledSeq);
4250
+ };
3619
4251
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
3620
4252
  // `.in` resume cursor, computed at most once per boot. The boot
3621
4253
  // block below resolves it (snapshot field or records scan) and the
3622
4254
  // resume-cursor block reuses it instead of re-scanning.
3623
4255
  let bootInCursor;
3624
4256
  let bootInCursorResolved = false;
3625
- if (!hydrateMessages && couldHavePriorState) {
4257
+ if (couldHavePriorState) {
3626
4258
  // Single parent span for the whole boot read phase — snapshot
3627
4259
  // read, session.out replay, session.in replay. Per-phase timing
3628
4260
  // + result counts are attributes on the span.
@@ -3630,18 +4262,33 @@ function chatAgent(options) {
3630
4262
  // snapshot read
3631
4263
  const snapStart = Date.now();
3632
4264
  try {
3633
- bootSnapshot = await readChatSnapshot(sessionIdForSnapshot);
4265
+ const loaded = hydrateMessages
4266
+ ? undefined
4267
+ : await transcriptStorage.load({
4268
+ chatId: payload.chatId,
4269
+ clientData: bootClientData,
4270
+ });
4271
+ if (loaded) {
4272
+ transcriptShadow = createTranscriptShadow(loaded.messages, new Set(loaded.nonFinalIds ?? []));
4273
+ bootTranscriptState = loaded.state;
4274
+ transcriptState = loaded.state ?? null;
4275
+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
4276
+ bootSnapshot = {
4277
+ messages: loaded.messages,
4278
+ lastOutEventId: loaded.cursors?.lastOutEventId,
4279
+ lastInEventId: loaded.cursors?.lastInEventId,
4280
+ };
4281
+ }
3634
4282
  }
3635
4283
  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", {
4284
+ logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
3639
4285
  error: error instanceof Error ? error.message : String(error),
3640
4286
  sessionId: sessionIdForSnapshot,
3641
4287
  });
3642
4288
  }
3643
4289
  bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
3644
- bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot);
4290
+ bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
4291
+ (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
3645
4292
  bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
3646
4293
  // Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
3647
4294
  // id of the previous turn's `turn-complete` control record). The
@@ -3649,6 +4296,8 @@ function chatAgent(options) {
3649
4296
  // Without seeding, the new worker would emit no trim on its first
3650
4297
  // turn (chain self-bootstraps from turn 2), so this is purely an
3651
4298
  // optimization to keep continuation runs bounded from the first turn.
4299
+ lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4300
+ lastSnapshotInEventId = bootSnapshot?.lastInEventId;
3652
4301
  if (bootSnapshot?.lastOutEventId !== undefined) {
3653
4302
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
3654
4303
  if (Number.isFinite(seeded)) {
@@ -3740,16 +4389,14 @@ function chatAgent(options) {
3740
4389
  }
3741
4390
  // ── session.in router ──────────────────────────────────────────
3742
4391
  //
3743
- // Reads the turn boundary and subscribes in one call. `bootInCursor` is
3744
- // only a fallback: the boot block above may already have resolved a
3745
- // cursor from the snapshot, which is used when the boundary itself
3746
- // carries none.
4392
+ const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
3747
4393
  await installChatInputRouter(payload.chatId, {
3748
4394
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4395
+ recoveredSeqNums,
3749
4396
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
3750
4397
  });
3751
4398
  // ── Recovery boot + chain reconstruction ────────────────────────
3752
- if (!hydrateMessages) {
4399
+ {
3753
4400
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
3754
4401
  const inFlightUsers = replayedInTail.map((r) => r.message);
3755
4402
  const partialAssistant = replayedPartial;
@@ -3828,7 +4475,7 @@ function chatAgent(options) {
3828
4475
  // branches: at n=1 the orphan partial is dropped and the interrupted
3829
4476
  // user is re-dispatched as a fresh turn instead.
3830
4477
  let seedChain;
3831
- let recoveredTurns;
4478
+ let recoveredEntries;
3832
4479
  if (hookChain !== undefined) {
3833
4480
  seedChain = hookChain;
3834
4481
  }
@@ -3839,13 +4486,26 @@ function chatAgent(options) {
3839
4486
  seedChain = settledMessages;
3840
4487
  }
3841
4488
  if (hookRecoveredTurns !== undefined) {
3842
- recoveredTurns = hookRecoveredTurns;
4489
+ const seqNumsByRecoveredId = new Map();
4490
+ for (const entry of replayedInTail) {
4491
+ const existing = seqNumsByRecoveredId.get(entry.message.id);
4492
+ if (existing)
4493
+ existing.push(entry.seqNum);
4494
+ else
4495
+ seqNumsByRecoveredId.set(entry.message.id, [entry.seqNum]);
4496
+ }
4497
+ recoveredEntries = hookRecoveredTurns.map((message) => ({
4498
+ message,
4499
+ seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4500
+ }));
3843
4501
  }
3844
4502
  else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
3845
- recoveredTurns = inFlightUsers.slice(1);
4503
+ recoveredEntries = replayedInTail
4504
+ .slice(1)
4505
+ .map((r) => ({ message: r.message, seqNum: r.seqNum }));
3846
4506
  }
3847
4507
  else {
3848
- recoveredTurns = inFlightUsers;
4508
+ recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum }));
3849
4509
  }
3850
4510
  // `beforeBoot` errors bubble — the customer opted into blocking
3851
4511
  // persistence and a failure there should fail the run rather than
@@ -3853,16 +4513,6 @@ function chatAgent(options) {
3853
4513
  if (hookBeforeBoot) {
3854
4514
  await hookBeforeBoot();
3855
4515
  }
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
4516
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
3867
4517
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
3868
4518
  // dispatch as normal turns with the existing hook stack.
@@ -3885,13 +4535,14 @@ function chatAgent(options) {
3885
4535
  for (const entry of replayedInTail) {
3886
4536
  metadataById.set(entry.message.id, entry.metadata);
3887
4537
  }
3888
- for (const msg of recoveredTurns) {
4538
+ const dispatchedRecoveredSeqs = new Set();
4539
+ for (const { message: msg, seqNum } of recoveredEntries) {
3889
4540
  if (wireMessageId && msg.id === wireMessageId)
3890
4541
  continue;
3891
4542
  const recoveredMetadata = metadataById.has(msg.id)
3892
4543
  ? metadataById.get(msg.id)
3893
4544
  : payload.metadata;
3894
- bootInjectedQueue.push({
4545
+ const injectedPayload = {
3895
4546
  chatId: payload.chatId,
3896
4547
  sessionId: payload.sessionId,
3897
4548
  metadata: recoveredMetadata,
@@ -3900,7 +4551,17 @@ function chatAgent(options) {
3900
4551
  messageId: msg.id,
3901
4552
  continuation: payload.continuation,
3902
4553
  previousRunId: payload.previousRunId,
3903
- });
4554
+ };
4555
+ bootInjectedQueue.push(injectedPayload);
4556
+ if (seqNum !== undefined) {
4557
+ recoveredSeqByPayload.set(injectedPayload, seqNum);
4558
+ dispatchedRecoveredSeqs.add(seqNum);
4559
+ }
4560
+ }
4561
+ for (const entry of replayedInTail) {
4562
+ if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4563
+ chatInputRouter().settleRecovered(entry.seqNum);
4564
+ }
3904
4565
  }
3905
4566
  accumulatedUIMessages = seedChain;
3906
4567
  // ── Head-start bootstrap ─────────────────────────────────────
@@ -3912,7 +4573,8 @@ function chatAgent(options) {
3912
4573
  // path where wire-borne UIMessage[] still seeds the accumulator,
3913
4574
  // and it's safe because the route handler isn't subject to the
3914
4575
  // `/in/append` 512 KiB cap.
3915
- if (accumulatedUIMessages.length === 0 &&
4576
+ if (!loadContextHook &&
4577
+ accumulatedUIMessages.length === 0 &&
3916
4578
  payload.trigger === "handover-prepare" &&
3917
4579
  Array.isArray(payload.headStartMessages) &&
3918
4580
  payload.headStartMessages.length > 0) {
@@ -3941,7 +4603,17 @@ function chatAgent(options) {
3941
4603
  }
3942
4604
  }
3943
4605
  try {
3944
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
4606
+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
4607
+ const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4608
+ accumulatedMessages = restored.messages;
4609
+ laneCompacted = restored.compacted;
4610
+ laneInjections = restored.injections;
4611
+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4612
+ locals.set(chatBackgroundQueueKey, [
4613
+ ...(locals.get(chatBackgroundQueueKey) ?? []),
4614
+ ...bootRuntimeState.queued,
4615
+ ]);
4616
+ }
3945
4617
  }
3946
4618
  catch (error) {
3947
4619
  logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
@@ -3955,6 +4627,12 @@ function chatAgent(options) {
3955
4627
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
3956
4628
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3957
4629
  }
4630
+ locals.set(chatAgentManagedConfigKey, {
4631
+ registry: promptRegistry,
4632
+ system: agentSystem,
4633
+ cacheControl: agentCacheControl,
4634
+ systemProviderOptions: agentSystemProviderOptions,
4635
+ });
3958
4636
  // Token usage tracking across turns
3959
4637
  let previousTurnUsage;
3960
4638
  let cumulativeUsage = emptyUsage();
@@ -4043,7 +4721,7 @@ function chatAgent(options) {
4043
4721
  */
4044
4722
  let dispatchedRecoveredFirstTurn = false;
4045
4723
  if (preloaded && bootInjectedQueue.length > 0) {
4046
- currentWirePayload = bootInjectedQueue.shift();
4724
+ currentWirePayload = dispatchBootInjected();
4047
4725
  dispatchedRecoveredFirstTurn = true;
4048
4726
  }
4049
4727
  // Handle preloaded runs — fire onPreload, then wait for the first real message
@@ -4251,7 +4929,7 @@ function chatAgent(options) {
4251
4929
  // waiting on the live session.in. Subsequent recovered turns
4252
4930
  // get drained by the end-of-turn picker below.
4253
4931
  if (bootInjectedQueue.length > 0) {
4254
- currentWirePayload = bootInjectedQueue.shift();
4932
+ currentWirePayload = dispatchBootInjected();
4255
4933
  }
4256
4934
  else {
4257
4935
  const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds;
@@ -4312,6 +4990,7 @@ function chatAgent(options) {
4312
4990
  } // end else (no boot-injected first turn)
4313
4991
  }
4314
4992
  for (let turn = 0; turn < maxTurns; turn++) {
4993
+ let turnClientData = payload.metadata;
4315
4994
  // Declared here so the finally can detach it — a handler leaked past
4316
4995
  // its turn duplicates every mid-stream message into the shared buffer.
4317
4996
  let turnMsgSub;
@@ -4333,6 +5012,7 @@ function chatAgent(options) {
4333
5012
  // `cleanupAbortedParts` no longer has to be re-applied below.
4334
5013
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4335
5014
  const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
5015
+ turnClientData = clientData;
4336
5016
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
4337
5017
  // Actions are not turns. They use a different span name
4338
5018
  // and don't carry a turn.number. Branched on at `isAction`.
@@ -4367,8 +5047,10 @@ function chatAgent(options) {
4367
5047
  // (errors are caught by the outer try/catch which writes an error chunk)
4368
5048
  locals.set(chatPipeCountKey, 0);
4369
5049
  locals.set(chatDeferKey, new Set());
5050
+ locals.set(chatOutGateKey, createChatOutGate());
4370
5051
  locals.set(chatCompactionStateKey, undefined);
4371
5052
  locals.set(chatSteeringQueueKey, []);
5053
+ locals.set(chatPendingBackgroundKey, []);
4372
5054
  locals.set(chatResponsePartsKey, []);
4373
5055
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
4374
5056
  // by deferred work from the previous turn's onTurnComplete need to
@@ -4450,6 +5132,10 @@ function chatAgent(options) {
4450
5132
  // Track new messages for this turn (user input + assistant response).
4451
5133
  const turnNewModelMessages = [];
4452
5134
  const turnNewUIMessages = [];
5135
+ locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
5136
+ // A head-start handover deliberately resumes from an assistant
5137
+ // message it spliced in, so it isn't a no-op turn.
5138
+ let splicedHandoverPartial = false;
4453
5139
  // ── Action handling ──────────────────────────────────────
4454
5140
  // Actions arrive on the same input stream but with
4455
5141
  // trigger === "action". They are NOT turns — only
@@ -4460,16 +5146,25 @@ function chatAgent(options) {
4460
5146
  // an action, return a `StreamTextResult` (auto-piped),
4461
5147
  // string, or UIMessage from `onAction`. Turn counter
4462
5148
  // does not advance.
4463
- let actionStreamResult = undefined;
5149
+ let actionResult = undefined;
5150
+ /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
5151
+ let actionTurn = false;
5152
+ /**
5153
+ * Whether this action changed the conversation, by rolling history
5154
+ * back or by streaming a response. Drives the single snapshot write
5155
+ * at the end — an action never reaches the turn-complete path that
5156
+ * normally does it.
5157
+ */
5158
+ let actionChangedHistory = false;
4464
5159
  if (isAction) {
4465
5160
  // Parse and validate the action payload
4466
5161
  const parsedAction = parseAction
4467
5162
  ? await parseAction(currentWirePayload.action)
4468
5163
  : currentWirePayload.action;
4469
5164
  // Hydrate messages from backend if configured
4470
- if (hydrateMessages) {
5165
+ if (loadContextHook) {
4471
5166
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
4472
- return hydrateMessages({
5167
+ return loadContextHook({
4473
5168
  chatId: currentWirePayload.chatId,
4474
5169
  turn,
4475
5170
  trigger: "action",
@@ -4489,12 +5184,14 @@ function chatAgent(options) {
4489
5184
  });
4490
5185
  accumulatedUIMessages = [...hydrated];
4491
5186
  accumulatedMessages = await toModelMessages(hydrated);
5187
+ laneCompacted = false;
5188
+ laneInjections = [];
4492
5189
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4493
5190
  }
4494
5191
  // Fire onAction — handler may mutate state via
4495
5192
  // `chat.history.*` and / or return a model response.
4496
5193
  if (onAction) {
4497
- actionStreamResult = await tracer.startActiveSpan("onAction()", async () => {
5194
+ actionResult = await tracer.startActiveSpan("onAction()", async () => {
4498
5195
  return await onAction({
4499
5196
  action: parsedAction,
4500
5197
  chatId: currentWirePayload.chatId,
@@ -4519,7 +5216,10 @@ function chatAgent(options) {
4519
5216
  locals.set(chatOverrideMessagesKey, undefined);
4520
5217
  accumulatedUIMessages = [...actionOverride];
4521
5218
  accumulatedMessages = await toModelMessages(actionOverride);
5219
+ laneCompacted = false;
5220
+ laneInjections = [];
4522
5221
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5222
+ actionChangedHistory = true;
4523
5223
  }
4524
5224
  }
4525
5225
  else {
@@ -4540,7 +5240,7 @@ function chatAgent(options) {
4540
5240
  // route handler's first-turn history to the hydrate hook as
4541
5241
  // incoming messages instead (gated on the pending handover).
4542
5242
  if (turn === 0 &&
4543
- hydrateMessages &&
5243
+ loadContextHook &&
4544
5244
  cleanedUIMessages.length === 0 &&
4545
5245
  (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
4546
5246
  Array.isArray(payload.headStartMessages) &&
@@ -4570,7 +5270,7 @@ function chatAgent(options) {
4570
5270
  },
4571
5271
  }));
4572
5272
  }
4573
- if (hydrateMessages) {
5273
+ if (loadContextHook) {
4574
5274
  // Snapshot the ids the accumulator knew BEFORE this
4575
5275
  // turn ran — used below to decide whether an
4576
5276
  // incoming wire message is genuinely new or just a
@@ -4588,7 +5288,7 @@ function chatAgent(options) {
4588
5288
  // was always true for `submit-message` is now true for every
4589
5289
  // trigger.
4590
5290
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
4591
- return hydrateMessages({
5291
+ return loadContextHook({
4592
5292
  chatId: currentWirePayload.chatId,
4593
5293
  turn,
4594
5294
  trigger: currentWirePayload.trigger,
@@ -4627,6 +5327,8 @@ function chatAgent(options) {
4627
5327
  }
4628
5328
  accumulatedUIMessages = merged;
4629
5329
  accumulatedMessages = await toModelMessages(merged);
5330
+ laneCompacted = false;
5331
+ laneInjections = [];
4630
5332
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4631
5333
  // Track new messages for onTurnComplete.newUIMessages.
4632
5334
  // Only push for genuinely new ids — HITL continuations
@@ -4672,6 +5374,8 @@ function chatAgent(options) {
4672
5374
  accumulatedUIMessages.pop();
4673
5375
  }
4674
5376
  accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5377
+ laneCompacted = false;
5378
+ laneInjections = [];
4675
5379
  }
4676
5380
  else if (cleanedUIMessages.length > 0) {
4677
5381
  // Submit-message (and the special-cased
@@ -4688,6 +5392,7 @@ function chatAgent(options) {
4688
5392
  // where AI SDK regenerates the id (TRI-9137) still
4689
5393
  // applies via `rewriteIncomingIdViaToolCallMap`.
4690
5394
  let replaced = false;
5395
+ const replacedPairs = [];
4691
5396
  for (const raw of cleanedUIMessages) {
4692
5397
  let incoming = raw;
4693
5398
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -4699,7 +5404,9 @@ function chatAgent(options) {
4699
5404
  }
4700
5405
  }
4701
5406
  if (idx !== -1) {
4702
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5407
+ const previous = accumulatedUIMessages[idx];
5408
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5409
+ replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4703
5410
  replaced = true;
4704
5411
  }
4705
5412
  else {
@@ -4709,9 +5416,19 @@ function chatAgent(options) {
4709
5416
  recordToolCallIdsFromMessage(incoming);
4710
5417
  }
4711
5418
  if (replaced) {
4712
- // Replacement changes structure — reconvert all model
4713
- // messages instead of appending.
4714
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5419
+ let inPlace = true;
5420
+ for (const { previous, merged } of replacedPairs) {
5421
+ if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5422
+ inPlace = false;
5423
+ break;
5424
+ }
5425
+ }
5426
+ if (!inPlace) {
5427
+ logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5428
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5429
+ laneCompacted = false;
5430
+ laneInjections = [];
5431
+ }
4715
5432
  }
4716
5433
  else {
4717
5434
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -4754,10 +5471,34 @@ function chatAgent(options) {
4754
5471
  messageId: locals.get(chatHandoverMessageIdKey),
4755
5472
  });
4756
5473
  locals.set(chatHandoverPartialKey, []); // consume once
5474
+ splicedHandoverPartial = true;
4757
5475
  }
4758
5476
  }
4759
5477
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4760
5478
  } // end if (trigger !== "action")
5479
+ // ── No-op turn ──────────────────────────────────────────
5480
+ //
5481
+ // A submit that added no new user message and leaves the model
5482
+ // chain ending on an assistant message has nothing to answer —
5483
+ // calling the model would prefill its own last reply. Keyed on
5484
+ // the model tail, so a `tool`-terminated chain (a merged tool
5485
+ // approval) still runs.
5486
+ const isNoOpTurn = !isAction &&
5487
+ !splicedHandoverPartial &&
5488
+ currentWirePayload.trigger === "submit-message" &&
5489
+ turnNewUIMessages.length === 0 &&
5490
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5491
+ if (isNoOpTurn) {
5492
+ msgSub?.off();
5493
+ logger.warn("chat.agent: turn added no new user message; skipping the model", {
5494
+ chatId: currentWirePayload.chatId,
5495
+ messageId: currentWirePayload.messageId,
5496
+ });
5497
+ settleRecoveredTurn(currentWirePayload);
5498
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5499
+ // Not a turn — don't consume an iteration.
5500
+ turn--;
5501
+ }
4761
5502
  // ── Action result handling ──────────────────────────────
4762
5503
  // For action turns, skip the turn machinery entirely.
4763
5504
  // If `onAction` returned a stream / string / UIMessage,
@@ -4767,34 +5508,51 @@ function chatAgent(options) {
4767
5508
  // The turn counter is decremented so the next iteration
4768
5509
  // sees the same `turn` value — actions don't count.
4769
5510
  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
- });
5511
+ if (isActionTurn(actionResult)) {
5512
+ // Persist the edit before the turn starts, so a turn that is
5513
+ // cancelled or runs out of memory continues from the edited
5514
+ // history rather than from the snapshot the edit replaced.
5515
+ // The turn then does its own hooks, completion and snapshot.
5516
+ if (actionChangedHistory) {
5517
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4783
5518
  }
4784
- catch (error) {
4785
- if (error instanceof Error &&
4786
- error.name === "AbortError" &&
4787
- runSignal.aborted) {
4788
- return "exit";
4789
- }
4790
- throw error;
5519
+ actionTurn = true;
5520
+ }
5521
+ else if (actionResult !== undefined) {
5522
+ throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5523
+ "after the edit, return chat.turn() and a turn runs on the edited history. " +
5524
+ "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5525
+ }
5526
+ else {
5527
+ msgSub?.off();
5528
+ if (actionChangedHistory) {
5529
+ await writeSnapshotOutsideTurn("action", { turn, clientData });
4791
5530
  }
5531
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
5532
+ // Don't consume a turn iteration — actions aren't turns.
5533
+ turn--;
4792
5534
  }
4793
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4794
- // Don't consume a turn iteration — actions aren't turns.
4795
- turn--;
4796
5535
  }
4797
- if (!isAction) {
5536
+ // A no-op turn skips this block, and with it `followSessionPin`:
5537
+ // there is nothing to answer, so nothing to hand over.
5538
+ if ((!isAction || actionTurn) && !isNoOpTurn) {
5539
+ if (!hydrateMessages) {
5540
+ chatDeferBeforeOutput(saveTranscript({
5541
+ reason: "turn-start",
5542
+ messages: accumulatedUIMessages,
5543
+ turn,
5544
+ trigger: storageTrigger(currentWirePayload.trigger),
5545
+ clientData,
5546
+ lastOutEventId: lastSnapshotOutEventId,
5547
+ skipIfUnchanged: true,
5548
+ carryInCursor: true,
5549
+ }).catch((error) => {
5550
+ logger.warn("chat.agent: turn-start transcript write failed; a reload mid-answer may not show the message being answered", {
5551
+ error: error instanceof Error ? error.message : String(error),
5552
+ sessionId: sessionIdForSnapshot,
5553
+ });
5554
+ }));
5555
+ }
4798
5556
  // Mint a scoped public access token once per turn, reused for
4799
5557
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
4800
5558
  const currentRunId = ctx.run.id;
@@ -4883,6 +5641,8 @@ function chatAgent(options) {
4883
5641
  locals.set(chatOverrideMessagesKey, undefined);
4884
5642
  accumulatedUIMessages = [...turnStartOverride];
4885
5643
  accumulatedMessages = await toModelMessages(turnStartOverride);
5644
+ laneCompacted = false;
5645
+ laneInjections = [];
4886
5646
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4887
5647
  }
4888
5648
  }, {
@@ -4899,9 +5659,11 @@ function chatAgent(options) {
4899
5659
  },
4900
5660
  });
4901
5661
  }
5662
+ await followSessionPin(currentWirePayload.chatId, versionSkew);
4902
5663
  // 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.
5664
+ // skip run() and hand over to a fresh run on the new version. The
5665
+ // successor picks the message up off session.in; the transport only
5666
+ // keeps reading.
4905
5667
  if (locals.get(chatUpgradeRequestedKey)) {
4906
5668
  await writeUpgradeRequiredChunk();
4907
5669
  return "exit";
@@ -4941,7 +5703,12 @@ function chatAgent(options) {
4941
5703
  const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
4942
5704
  const bgQueue = locals.get(chatBackgroundQueueKey);
4943
5705
  if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
4944
- accumulatedMessages.push(...bgQueue.splice(0));
5706
+ const injected = bgQueue.splice(0);
5707
+ accumulatedMessages.push(...injected);
5708
+ laneInjections.push({
5709
+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
5710
+ messages: injected,
5711
+ });
4945
5712
  }
4946
5713
  if (isHeadStartFinalTurn) {
4947
5714
  // The synthesized partial UIMessage IS the response.
@@ -4960,6 +5727,9 @@ function chatAgent(options) {
4960
5727
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
4961
5728
  runResult = await userRun({
4962
5729
  ...restWire,
5730
+ // A turn requested by chat.turn() is not the action itself:
5731
+ // a run() that short-circuits on "action" must still answer.
5732
+ ...(actionTurn ? { trigger: "action-turn" } : {}),
4963
5733
  messages: preparedMessages,
4964
5734
  clientData,
4965
5735
  continuation,
@@ -4972,6 +5742,7 @@ function chatAgent(options) {
4972
5742
  signal: combinedSignal,
4973
5743
  cancelSignal,
4974
5744
  stopSignal,
5745
+ streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
4975
5746
  });
4976
5747
  }
4977
5748
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5085,7 +5856,21 @@ function chatAgent(options) {
5085
5856
  if (runOverride) {
5086
5857
  locals.set(chatOverrideMessagesKey, undefined);
5087
5858
  accumulatedUIMessages = [...runOverride];
5088
- accumulatedMessages = await toModelMessages(runOverride);
5859
+ /**
5860
+ * Steers the drain consumed are left out of the rebuild and
5861
+ * appended by the reconciliation below instead, so the lane
5862
+ * gets the form the model actually received rather than a
5863
+ * reconversion of the UI message, and gets it once. A steer
5864
+ * the edit removed is dropped from the pending list too, so
5865
+ * the edit is honoured.
5866
+ */
5867
+ const overrideIds = new Set(runOverride.map((m) => m.id));
5868
+ const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5869
+ locals.set(chatPendingSteerKey, pending);
5870
+ const pendingIds = new Set(pending.map((e) => e.ui.id));
5871
+ accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5872
+ laneCompacted = false;
5873
+ laneInjections = [];
5089
5874
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5090
5875
  }
5091
5876
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5108,6 +5893,8 @@ function chatAgent(options) {
5108
5893
  accumulatedMessages = taskCompactionConfig?.compactModelMessages
5109
5894
  ? await taskCompactionConfig.compactModelMessages(compactEvent)
5110
5895
  : modelOnlyOverride;
5896
+ laneCompacted = true;
5897
+ laneInjections = [];
5111
5898
  // Apply UI messages: callback or default (preserve all)
5112
5899
  if (taskCompactionConfig?.compactUIMessages) {
5113
5900
  accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
@@ -5115,10 +5902,20 @@ function chatAgent(options) {
5115
5902
  }
5116
5903
  // Determine if the user stopped generation this turn (not a full run cancel).
5117
5904
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5905
+ // Give the model accumulator the steering messages the drain
5906
+ // consumed. Appended, never reconverted from the UI lane, so a
5907
+ // model-only compaction summary set just above survives; and done
5908
+ // before the response is appended so the order stays
5909
+ // steer-then-answer. Outside the `capturedResponseMessage`
5910
+ // branches below, so a turn that captured no response is covered.
5911
+ const steerTailThisTurn = reconcilePendingSteer({
5912
+ turnNew: turnNewModelMessages,
5913
+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
5118
5914
  // Append the assistant's response (partial or complete) to the accumulator.
5119
5915
  // The onFinish callback fires even on abort/stop, so partial responses
5120
5916
  // from stopped generation are captured correctly.
5121
5917
  let rawResponseMessage;
5918
+ let responseWasSkipped = false;
5122
5919
  if (capturedResponseMessage) {
5123
5920
  // Keep the raw message before cleanup for users who want custom handling
5124
5921
  rawResponseMessage = capturedResponseMessage;
@@ -5144,43 +5941,56 @@ function chatAgent(options) {
5144
5941
  };
5145
5942
  locals.set(chatResponsePartsKey, []);
5146
5943
  }
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
- ]);
5944
+ const responseHasContent = capturedResponseMessage.parts.some((part) => part.type !== "step-start");
5945
+ if (responseHasContent) {
5946
+ // Tool-approval continuations: the AI SDK reuses the trailing
5947
+ // assistant's ID (via originalMessages) so the captured response
5948
+ // carries the same ID as an existing message. Replace in place
5949
+ // instead of pushing a duplicate. For action turns this never
5950
+ // matches because originalMessages is omitted (fresh ID).
5951
+ const existingIdx = capturedResponseMessage.id
5952
+ ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5953
+ : -1;
5954
+ const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5173
5955
  if (existingIdx !== -1) {
5174
- // Reconvert all model messages since we replaced rather than appended
5175
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5956
+ accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5176
5957
  }
5177
5958
  else {
5178
- accumulatedMessages.push(...responseModelMessages);
5959
+ accumulatedUIMessages.push(capturedResponseMessage);
5960
+ }
5961
+ turnNewUIMessages.push(capturedResponseMessage);
5962
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5963
+ // Record toolCallId → head messageId so a HITL
5964
+ // continuation next turn can recover the head id
5965
+ // even if the AI SDK regenerates it. See
5966
+ // `chatToolCallToMessageIdKey` for the full
5967
+ // rationale (TRI-9137).
5968
+ recordToolCallIdsFromMessage(capturedResponseMessage);
5969
+ try {
5970
+ const responseModelMessages = await toModelMessages([
5971
+ stripProviderMetadata(capturedResponseMessage),
5972
+ ]);
5973
+ if (existingIdx !== -1) {
5974
+ const ok = previousAtIdx !== undefined &&
5975
+ (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5976
+ if (!ok) {
5977
+ logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5978
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5979
+ laneCompacted = false;
5980
+ laneInjections = [];
5981
+ }
5982
+ }
5983
+ else {
5984
+ accumulatedMessages.push(...responseModelMessages);
5985
+ }
5986
+ turnNewModelMessages.push(...responseModelMessages);
5987
+ }
5988
+ catch {
5989
+ // Conversion failed — skip accumulation for this turn
5179
5990
  }
5180
- turnNewModelMessages.push(...responseModelMessages);
5181
5991
  }
5182
- catch {
5183
- // Conversion failed — skip accumulation for this turn
5992
+ else {
5993
+ responseWasSkipped = true;
5184
5994
  }
5185
5995
  }
5186
5996
  // If there's no captured response (manual pipe mode) but there are
@@ -5280,6 +6090,8 @@ function chatAgent(options) {
5280
6090
  ],
5281
6091
  },
5282
6092
  ];
6093
+ laneCompacted = true;
6094
+ laneInjections = [];
5283
6095
  // UI messages: callback or default (preserve all)
5284
6096
  if (outerCompaction.compactUIMessages) {
5285
6097
  accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
@@ -5369,6 +6181,8 @@ function chatAgent(options) {
5369
6181
  locals.set(chatOverrideMessagesKey, undefined);
5370
6182
  accumulatedUIMessages = [...override];
5371
6183
  accumulatedMessages = await toModelMessages(override);
6184
+ laneCompacted = false;
6185
+ laneInjections = [];
5372
6186
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5373
6187
  // Update event so onTurnComplete sees compacted messages
5374
6188
  turnCompleteEvent.messages = accumulatedMessages;
@@ -5397,9 +6211,23 @@ function chatAgent(options) {
5397
6211
  capturedPartialResponse = capturedResponseMessage;
5398
6212
  turnCompleteEvent.responseMessage = capturedResponseMessage;
5399
6213
  turnCompleteEvent.uiMessages = accumulatedUIMessages;
6214
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6215
+ }
6216
+ else if (responseWasSkipped) {
6217
+ capturedResponseMessage = {
6218
+ ...capturedResponseMessage,
6219
+ parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
6220
+ };
6221
+ accumulatedUIMessages.push(capturedResponseMessage);
6222
+ turnNewUIMessages.push(capturedResponseMessage);
6223
+ capturedPartialResponse = capturedResponseMessage;
6224
+ turnCompleteEvent.responseMessage = capturedResponseMessage;
6225
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
6226
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5400
6227
  }
5401
6228
  locals.set(chatResponsePartsKey, []);
5402
6229
  }
6230
+ settleRecoveredTurn(currentWirePayload);
5403
6231
  // Write turn-complete control chunk — closes the frontend stream.
5404
6232
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
5405
6233
  // Fire onTurnComplete — stream is closed, use for persistence.
@@ -5415,6 +6243,8 @@ function chatAgent(options) {
5415
6243
  locals.set(chatOverrideMessagesKey, undefined);
5416
6244
  accumulatedUIMessages = [...turnCompleteOverride];
5417
6245
  accumulatedMessages = await toModelMessages(turnCompleteOverride);
6246
+ laneCompacted = false;
6247
+ laneInjections = [];
5418
6248
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5419
6249
  }
5420
6250
  }, {
@@ -5468,13 +6298,18 @@ function chatAgent(options) {
5468
6298
  if (!hydrateMessages) {
5469
6299
  try {
5470
6300
  await tracer.startActiveSpan("snapshot.write", async () => {
5471
- const snapshotInCursor = chatInputRouter().resumeFloor();
5472
- await writeChatSnapshot(sessionIdForSnapshot, {
5473
- version: 1,
5474
- savedAt: Date.now(),
6301
+ lastSnapshotOutEventId =
6302
+ turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6303
+ await saveTranscript({
6304
+ reason: "turn-complete",
5475
6305
  messages: accumulatedUIMessages,
5476
- lastOutEventId: turnCompleteResult?.lastEventId,
5477
- lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6306
+ turn,
6307
+ trigger: storageTrigger(currentWirePayload.trigger),
6308
+ clientData,
6309
+ lastOutEventId: lastSnapshotOutEventId,
6310
+ nonFinalIds: wasStopped && capturedResponseMessage?.id
6311
+ ? new Set([capturedResponseMessage.id])
6312
+ : undefined,
5478
6313
  });
5479
6314
  }, {
5480
6315
  attributes: {
@@ -5504,13 +6339,20 @@ function chatAgent(options) {
5504
6339
  // produced these from in-flight user messages on session.in
5505
6340
  // that the dead predecessor never acknowledged.
5506
6341
  if (bootInjectedQueue.length > 0) {
5507
- currentWirePayload = bootInjectedQueue.shift();
6342
+ currentWirePayload = dispatchBootInjected();
5508
6343
  return "continue";
5509
6344
  }
5510
- // chat.requestUpgrade() was called — exit the loop so the
5511
- // transport triggers a new run on the latest version.
6345
+ // chat.requestUpgrade() was called — exit the loop; the handover
6346
+ // has already triggered a new run on the latest version.
5512
6347
  // chat.endRun() — same exit, no upgrade semantics.
6348
+ if (locals.get(chatCloseRequestedKey)) {
6349
+ await performChatClose();
6350
+ return "exit";
6351
+ }
5513
6352
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6353
+ if (locals.get(chatUpgradeRequestedKey)) {
6354
+ await persistUpgradeHandoff();
6355
+ }
5514
6356
  return "exit";
5515
6357
  }
5516
6358
  // Wait for the next message — stay idle briefly, then suspend
@@ -5612,11 +6454,17 @@ function chatAgent(options) {
5612
6454
  let errorTurnCompleteResult;
5613
6455
  try {
5614
6456
  await withChatWriter(async (writer) => {
5615
- const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
6457
+ const errorText = turnError instanceof Error && turnError.message
6458
+ ? turnError.message
6459
+ : "An unexpected error occurred";
5616
6460
  writer.write({ type: "error", errorText });
5617
6461
  });
5618
6462
  // Signal turn complete so the client knows this turn is done
5619
6463
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6464
+ // A later action's snapshot reuses this cursor, so it has to move
6465
+ // here too or that snapshot resumes from before the failed turn.
6466
+ lastSnapshotOutEventId =
6467
+ errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
5620
6468
  }
5621
6469
  catch {
5622
6470
  // Best-effort — if stream write fails, let the run continue anyway
@@ -5656,20 +6504,56 @@ function chatAgent(options) {
5656
6504
  }
5657
6505
  }
5658
6506
  const includePartial = partialResponse != null && !responseCommitted;
6507
+ // What the stream left behind, by content. After `onTurnComplete` the
6508
+ // partial is still unfinished only if the message under its id is
6509
+ // byte-for-byte this: a clone keeps it partial, an edit finishes it.
6510
+ const partialFingerprint = includePartial && partialResponse ? fingerprintMessage(partialResponse) : undefined;
5659
6511
  let erroredUIMessagesWithPartial = !includePartial
5660
6512
  ? erroredUIMessages
5661
6513
  : partialIdx === -1
5662
6514
  ? [...erroredUIMessages, partialResponse]
5663
6515
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
5664
- let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5665
- if (includePartial) {
5666
- erroredNewUIMessages.push(partialResponse);
5667
- }
6516
+ /**
6517
+ * Seeded from the per-turn list, not just the wire message and the
6518
+ * partial, so a steering message the drain consumed is reported too.
6519
+ * An app persisting from `newUIMessages` would otherwise lose the
6520
+ * instruction whenever the turn it steered went on to fail.
6521
+ */
6522
+ const buildErroredNew = () => {
6523
+ const out = [];
6524
+ const addUnique = (m) => {
6525
+ if (m && !out.some((existing) => existing.id === m.id))
6526
+ out.push(m);
6527
+ };
6528
+ addUnique(erroredWireMessage);
6529
+ for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6530
+ addUnique(m);
6531
+ }
6532
+ if (includePartial)
6533
+ addUnique(partialResponse);
6534
+ return out;
6535
+ };
6536
+ let erroredNewUIMessages = buildErroredNew();
5668
6537
  let erroredNewModelMessages = [];
6538
+ const reconciledSteer = reconcilePendingSteer();
6539
+ const backgroundTailThisTurn = reconcilePendingBackground();
5669
6540
  if (!responseCommitted) {
5670
6541
  try {
5671
6542
  if (erroredNewUIMessages.length > 0) {
5672
- erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6543
+ /**
6544
+ * Built in order from the recorded forms rather than by
6545
+ * converting the UI list, so a steer appears in the delta as
6546
+ * the model received it (what `prepare` produced), matching the
6547
+ * lane. The wire message and partial are converted as before.
6548
+ */
6549
+ const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6550
+ for (const m of erroredNewUIMessages) {
6551
+ const recorded = steerModelById.get(m.id);
6552
+ if (recorded)
6553
+ erroredNewModelMessages.push(...recorded);
6554
+ else
6555
+ erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6556
+ }
5673
6557
  }
5674
6558
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
5675
6559
  if (partialIdx === -1) {
@@ -5677,7 +6561,14 @@ function chatAgent(options) {
5677
6561
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
5678
6562
  }
5679
6563
  else {
5680
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6564
+ const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
6565
+ backgroundTailThisTurn);
6566
+ if (!ok) {
6567
+ logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6568
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6569
+ laneCompacted = false;
6570
+ laneInjections = [];
6571
+ }
5681
6572
  }
5682
6573
  accumulatedUIMessages = erroredUIMessagesWithPartial;
5683
6574
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -5686,9 +6577,14 @@ function chatAgent(options) {
5686
6577
  catch {
5687
6578
  erroredNewModelMessages = [];
5688
6579
  erroredUIMessagesWithPartial = erroredUIMessages;
5689
- erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6580
+ erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5690
6581
  }
5691
6582
  }
6583
+ // An earlier hook that set the history and then threw (which is one way
6584
+ // to get here) left its abandoned edit pending. Discard it before the
6585
+ // failed turn continues, so neither the error-path `onTurnComplete`
6586
+ // below nor the next turn's history reads mistake it for a real edit.
6587
+ locals.set(chatOverrideMessagesKey, undefined);
5692
6588
  if (onTurnComplete) {
5693
6589
  try {
5694
6590
  await tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -5717,6 +6613,23 @@ function chatAgent(options) {
5717
6613
  error: turnError,
5718
6614
  lastEventId: errorTurnCompleteResult?.lastEventId,
5719
6615
  });
6616
+ // The hook may edit the history here too (a failure record, a
6617
+ // card the turn left open). Honour it the way the success path
6618
+ // does, so the edit reaches the accumulator and the save below.
6619
+ const errorTurnOverride = locals.get(chatOverrideMessagesKey);
6620
+ if (errorTurnOverride) {
6621
+ locals.set(chatOverrideMessagesKey, undefined);
6622
+ // Convert first: a rejected conversion (a tool's `toModelOutput`
6623
+ // can throw) must leave every lane on the history it had.
6624
+ const overrideUIMessages = [...errorTurnOverride];
6625
+ const overrideModelMessages = await toModelMessages(errorTurnOverride);
6626
+ erroredUIMessagesWithPartial = overrideUIMessages;
6627
+ accumulatedUIMessages = overrideUIMessages;
6628
+ accumulatedMessages = overrideModelMessages;
6629
+ laneCompacted = false;
6630
+ laneInjections = [];
6631
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6632
+ }
5720
6633
  }, {
5721
6634
  attributes: {
5722
6635
  [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
@@ -5730,6 +6643,7 @@ function chatAgent(options) {
5730
6643
  catch {
5731
6644
  // A throwing onTurnComplete on the error path must not crash
5732
6645
  // the run — keep the conversation alive for the next message.
6646
+ locals.set(chatOverrideMessagesKey, undefined);
5733
6647
  }
5734
6648
  }
5735
6649
  // Persist a snapshot so the failed turn's user message isn't
@@ -5740,13 +6654,17 @@ function chatAgent(options) {
5740
6654
  // neither the snapshot nor the replayable `.in` tail.
5741
6655
  if (!hydrateMessages) {
5742
6656
  try {
5743
- const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5744
- await writeChatSnapshot(sessionIdForSnapshot, {
5745
- version: 1,
5746
- savedAt: Date.now(),
6657
+ await saveTranscript({
6658
+ reason: "turn-error",
5747
6659
  messages: erroredUIMessagesWithPartial,
5748
- lastOutEventId: errorTurnCompleteResult?.lastEventId,
5749
- lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
6660
+ turn,
6661
+ trigger: storageTrigger(currentWirePayload.trigger),
6662
+ clientData: turnClientData,
6663
+ lastOutEventId: lastSnapshotOutEventId,
6664
+ // The partial is non-final only while the message under its id is
6665
+ // still what the stream left behind. A hook that replaced it (a
6666
+ // closed card, a finished body) produced a final message.
6667
+ nonFinalIds: partialStillUnfinished(partialResponse, partialFingerprint, erroredUIMessagesWithPartial),
5750
6668
  });
5751
6669
  }
5752
6670
  catch (error) {
@@ -5756,15 +6674,22 @@ function chatAgent(options) {
5756
6674
  });
5757
6675
  }
5758
6676
  }
6677
+ if (locals.get(chatCloseRequestedKey)) {
6678
+ await performChatClose();
6679
+ return;
6680
+ }
5759
6681
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
5760
6682
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6683
+ if (locals.get(chatUpgradeRequestedKey)) {
6684
+ await persistUpgradeHandoff();
6685
+ }
5761
6686
  return;
5762
6687
  }
5763
6688
  // Drain remaining recovered turns before idling — a thrown
5764
6689
  // recovered turn shouldn't strand the rest of the boot queue
5765
6690
  // until an unrelated live message arrives.
5766
6691
  if (bootInjectedQueue.length > 0) {
5767
- currentWirePayload = bootInjectedQueue.shift();
6692
+ currentWirePayload = dispatchBootInjected();
5768
6693
  continue;
5769
6694
  }
5770
6695
  // Wait for the next message — same as after a successful turn
@@ -5780,6 +6705,12 @@ function chatAgent(options) {
5780
6705
  return; // Timed out — end run gracefully
5781
6706
  }
5782
6707
  currentWirePayload = next.output;
6708
+ // Same close check the success path makes. Without it a close
6709
+ // record that lands after a failed turn is consumed as if it were
6710
+ // a turn payload, and the loop runs on against a closed session.
6711
+ if (currentWirePayload.trigger === "close") {
6712
+ return;
6713
+ }
5783
6714
  // Continue to next iteration of the for loop
5784
6715
  }
5785
6716
  finally {
@@ -5788,6 +6719,11 @@ function chatAgent(options) {
5788
6719
  }
5789
6720
  }
5790
6721
  finally {
6722
+ // Safety net for a close requested on a path that exits without
6723
+ // reaching one of the loop's close checks (a turn timeout, an OOM
6724
+ // re-throw). `performChatClose` is idempotent, so the ordinary path
6725
+ // having already run it costs nothing here.
6726
+ await performChatClose();
5791
6727
  // `stopSub` is registered post-preload so the close-during-preload
5792
6728
  // early-return path may exit before it ever attached. Guard the
5793
6729
  // cleanup so a missing subscription doesn't throw.
@@ -6125,15 +7061,22 @@ function isStopped() {
6125
7061
  // Version upgrade
6126
7062
  // ---------------------------------------------------------------------------
6127
7063
  /**
6128
- * Request that the current run exits so the next message starts on the latest
6129
- * deployed version (via the standard continuation mechanism).
7064
+ * Hand the conversation over to another deployment.
7065
+ *
7066
+ * The handover happens immediately and server-side: a successor run is created
7067
+ * and picks the conversation up from `session.in`. The transport keeps reading
7068
+ * the same session output, so no client action is needed and nothing waits for
7069
+ * the next message.
7070
+ *
7071
+ * Without a target the session's pin is cleared, so the successor lands on the
7072
+ * latest deployed version; with `externalDeploymentId` the session is re-pinned
7073
+ * to that deployment.
6130
7074
  *
6131
7075
  * 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.
7076
+ * entirely and the successor answers the message that opened the turn.
6134
7077
  *
6135
7078
  * 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.
7079
+ * normally and the handover happens afterward.
6137
7080
  *
6138
7081
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6139
7082
  * or inside `chat.defer()`.
@@ -6153,8 +7096,37 @@ function isStopped() {
6153
7096
  * });
6154
7097
  * ```
6155
7098
  */
6156
- function requestUpgrade() {
7099
+ function requestUpgrade(options) {
6157
7100
  locals.set(chatUpgradeRequestedKey, true);
7101
+ // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
7102
+ const target = options?.externalDeploymentId?.trim();
7103
+ if (target)
7104
+ locals.set(chatUpgradeExternalDeploymentIdKey, target);
7105
+ }
7106
+ /** @internal Requests a handoff when the session's pin no longer names this deployment. */
7107
+ async function followSessionPin(chatId, policy) {
7108
+ if (!chatId) {
7109
+ return;
7110
+ }
7111
+ const deployedExternalId = locals.get(chatAgentRunContextKey)?.deployment?.externalId;
7112
+ if (policy !== "hold" && !deployedExternalId) {
7113
+ logger.debug("chat.versionSkew: cannot follow the session pin", {
7114
+ chatId,
7115
+ reason: "the run context carries no deployment.externalId",
7116
+ });
7117
+ }
7118
+ const target = await resolvePinToFollow({
7119
+ policy,
7120
+ deployedExternalId,
7121
+ upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
7122
+ readPin: async () => (await sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
7123
+ .triggerConfig,
7124
+ });
7125
+ if (!target) {
7126
+ return;
7127
+ }
7128
+ logger.info("chat.versionSkew: following the session pin", { chatId, target });
7129
+ requestUpgrade({ externalDeploymentId: target });
6158
7130
  }
6159
7131
  /**
6160
7132
  * Hand off the current custom agent Session to a fresh run.
@@ -6193,20 +7165,31 @@ async function endAndContinue() {
6193
7165
  if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6194
7166
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6195
7167
  }
6196
- await performEndAndContinue();
7168
+ await performEndAndContinue({ reason: "continuation" });
6197
7169
  }
6198
7170
  /** @internal Shared server handoff used by managed and custom agent loops. */
6199
- async function performEndAndContinue() {
7171
+ async function performEndAndContinue(options) {
6200
7172
  const chatId = locals.get(chatExternalIdKey);
6201
7173
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
6202
7174
  if (!chatId || !callingRunId) {
6203
7175
  throw new Error("Cannot end and continue without an active chat agent run");
6204
7176
  }
7177
+ const externalDeploymentId = options.externalDeploymentId;
6205
7178
  const apiClient = apiClientManager.clientOrThrow();
6206
- await apiClient.endAndContinueSession(chatId, {
7179
+ const result = await apiClient.endAndContinueSession(chatId, {
6207
7180
  callingRunId,
6208
- reason: "upgrade",
7181
+ reason: options.reason,
7182
+ ...(externalDeploymentId ? { externalDeploymentId } : {}),
6209
7183
  });
7184
+ if (result?.pendingVersion !== true) {
7185
+ return;
7186
+ }
7187
+ // The successor parked. Say so on `.out` while this run still can — the transport's
7188
+ // subscription survives the swap, so the client learns without waiting for its next send.
7189
+ const [error] = await tryCatch(getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
7190
+ if (error) {
7191
+ logger.warn("could not signal a parked handoff", { chatId, error });
7192
+ }
6210
7193
  }
6211
7194
  /**
6212
7195
  * Exit the run after the current turn completes, without waiting for the
@@ -6237,6 +7220,124 @@ async function performEndAndContinue() {
6237
7220
  function endRun() {
6238
7221
  locals.set(chatEndRunRequestedKey, true);
6239
7222
  }
7223
+ /**
7224
+ * End the whole conversation, permanently. The session row is closed, further
7225
+ * appends are refused, and the run exits without scheduling a continuation.
7226
+ *
7227
+ * This is the session-level stop. {@link endRun} ends the current run and lets
7228
+ * the next message start a fresh one; `chat.close()` ends the session itself,
7229
+ * so there is no next message. Use it for a budget cap, a completed goal,
7230
+ * abuse detection, or a user signing out.
7231
+ *
7232
+ * In a `chat.agent`, call it from `run()`, `prepareStep`, or
7233
+ * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
7234
+ * `streamText` the same way the stop signal does, so the partial response is
7235
+ * still captured and streamed. The turn then completes normally, a terminal
7236
+ * `session-closed` record carrying `reason` is written to the response stream,
7237
+ * and the loop exits.
7238
+ *
7239
+ * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
7240
+ * fields but still runs while the stream is open, so the closed state rides
7241
+ * out on the turn's final record. `onTurnComplete` runs after that record, so
7242
+ * a close decided there does not reach a reader that has already finished the
7243
+ * turn, and the user only finds out when their next message is refused.
7244
+ *
7245
+ * In a `chat.customAgent`, call it anywhere in your own loop. The close is
7246
+ * performed when `run()` returns, so it lands whether you break out of a
7247
+ * `chat.createSession` loop, return early, or hand-roll the loop entirely.
7248
+ *
7249
+ * Closing is one-way: a closed session cannot be reopened. Its transcript
7250
+ * stays readable.
7251
+ *
7252
+ * @example
7253
+ * ```ts
7254
+ * chat.agent({
7255
+ * id: "budgeted-agent",
7256
+ * onBeforeTurnComplete: async ({ usage }) => {
7257
+ * if (await overBudget(usage)) {
7258
+ * chat.close({ reason: "Monthly budget reached" });
7259
+ * }
7260
+ * },
7261
+ * });
7262
+ * ```
7263
+ */
7264
+ function close(options) {
7265
+ if (!locals.get(chatExternalIdKey)) {
7266
+ throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
7267
+ }
7268
+ // Bound the reason once, here. It goes out on S2 record headers as well as
7269
+ // the close API, and an oversized value would fail the turn-complete write
7270
+ // that carries the turn boundary, costing the client far more than the
7271
+ // reason text.
7272
+ // Trailing high surrogate: the cut landed between the two halves of an
7273
+ // astral character, and encoding the orphan to UTF-8 for a record header
7274
+ // yields a replacement character. Drop it rather than ship mojibake.
7275
+ const reason = options?.reason
7276
+ ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
7277
+ .replace(/[\uD800-\uDBFF]$/, "");
7278
+ locals.set(chatCloseRequestedKey, reason ? { reason } : {});
7279
+ // Mid-step call: unblock the in-flight streamText exactly like the stop
7280
+ // signal, so the turn can reach its turn boundary instead of running the
7281
+ // model out to completion after the decision to close has been made.
7282
+ locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
7283
+ }
7284
+ /**
7285
+ * @internal Terminal close sequence, run once at whichever exit site observes
7286
+ * the close request. Writes the standalone `session-closed` record, then closes
7287
+ * the session row.
7288
+ *
7289
+ * The record lands after the turn's `turn-complete`, so a client reading that
7290
+ * turn's stream has already terminated on it and will not see this one. It is
7291
+ * there for a reconnect and for replay. What a live client reads is the
7292
+ * `session-closed` header stamped onto `turn-complete` itself by
7293
+ * `writeTurnCompleteChunk`, which fires whenever the close was decided before
7294
+ * the turn ended. A close decided from `onTurnComplete` is past that point, so
7295
+ * the client learns from the 409 on its next send.
7296
+ */
7297
+ async function performChatClose() {
7298
+ const request = locals.get(chatCloseRequestedKey);
7299
+ if (!request || locals.get(chatClosePerformedKey))
7300
+ return;
7301
+ const reason = request.reason;
7302
+ // Two flags, not one. The record is a client-visible event and must not be
7303
+ // written twice, but the row close is the part that actually ends the
7304
+ // conversation: flagging it as done before it succeeds would let a transient
7305
+ // failure leave the session open with no later call willing to retry.
7306
+ if (!locals.get(chatCloseRecordWrittenKey)) {
7307
+ locals.set(chatCloseRecordWrittenKey, true);
7308
+ try {
7309
+ const session = getChatSession();
7310
+ await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
7311
+ }
7312
+ catch (error) {
7313
+ logger.warn("chat.close: failed to write the session-closed record", {
7314
+ error: error instanceof Error ? error.message : String(error),
7315
+ });
7316
+ }
7317
+ }
7318
+ const chatId = locals.get(chatExternalIdKey);
7319
+ if (!chatId)
7320
+ return;
7321
+ try {
7322
+ await sessions.close(chatId, {
7323
+ ...(reason ? { reason } : {}),
7324
+ ...(locals.get(chatAgentRunContextKey)?.run.id
7325
+ ? { callingRunId: locals.get(chatAgentRunContextKey).run.id }
7326
+ : {}),
7327
+ });
7328
+ locals.set(chatClosePerformedKey, true);
7329
+ }
7330
+ catch (error) {
7331
+ // Deliberately NOT flagged as performed: the close API is idempotent, so a
7332
+ // later exit site on this run gets to retry it. Losing every retry to a
7333
+ // transient failure would leave the row open and the conversation alive.
7334
+ // Non-fatal either way — the run still exits.
7335
+ logger.error("chat.close: failed to close the session", {
7336
+ chatId,
7337
+ error: error instanceof Error ? error.message : String(error),
7338
+ });
7339
+ }
7340
+ }
6240
7341
  // ---------------------------------------------------------------------------
6241
7342
  // Per-turn deferred work
6242
7343
  // ---------------------------------------------------------------------------
@@ -6298,9 +7399,18 @@ function chatDefer(promiseOrFn) {
6298
7399
  * ```
6299
7400
  */
6300
7401
  function injectBackgroundContext(messages) {
6301
- const queue = locals.get(chatBackgroundQueueKey) ?? [];
6302
- queue.push(...messages);
6303
- locals.set(chatBackgroundQueueKey, queue);
7402
+ const systemBlocks = messages.filter((message) => message.role === "system");
7403
+ const conversational = messages.filter((message) => message.role !== "system");
7404
+ if (systemBlocks.length > 0) {
7405
+ const instructions = locals.get(chatInjectedInstructionsKey) ?? [];
7406
+ instructions.push(...systemBlocks);
7407
+ locals.set(chatInjectedInstructionsKey, instructions);
7408
+ }
7409
+ if (conversational.length > 0) {
7410
+ const queue = locals.get(chatBackgroundQueueKey) ?? [];
7411
+ queue.push(...conversational);
7412
+ locals.set(chatBackgroundQueueKey, queue);
7413
+ }
6304
7414
  }
6305
7415
  // ---------------------------------------------------------------------------
6306
7416
  // Aborted message cleanup
@@ -6713,10 +7823,12 @@ class ChatMessageAccumulator {
6713
7823
  // a duplicate, mirroring the chat.agent accumulator.
6714
7824
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
6715
7825
  if (existingIdx !== -1) {
7826
+ const previous = this.uiMessages[existingIdx];
6716
7827
  this.uiMessages[existingIdx] = response;
6717
7828
  try {
6718
- // Reconvert all model messages since we replaced rather than appended.
6719
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7829
+ if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7830
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7831
+ }
6720
7832
  }
6721
7833
  catch {
6722
7834
  // Conversion failed — leave the existing model messages in place
@@ -6752,6 +7864,28 @@ class ChatMessageAccumulator {
6752
7864
  const modelMsgs = await toModelMessages([message]);
6753
7865
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
6754
7866
  }
7867
+ /**
7868
+ * Record the messages a steering drain consumed.
7869
+ *
7870
+ * The drain only puts them in this step's prompt, so without this they
7871
+ * shape one answer and then exist in neither lane: not in `uiMessages`,
7872
+ * which is what an app persists from, and not in `modelMessages`, which is
7873
+ * what every later turn sends.
7874
+ *
7875
+ * Both lanes are appended to. The model lane is never reconverted from the
7876
+ * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7877
+ * the UI lane whole: a reconversion would restore everything the summary
7878
+ * replaced.
7879
+ */
7880
+ async absorbSteering(claimed, injected) {
7881
+ const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7882
+ if (fresh.length === 0)
7883
+ return;
7884
+ this.uiMessages.push(...fresh);
7885
+ // Record what the model received. Only when the whole batch is new is
7886
+ // `injected` known to describe exactly these messages.
7887
+ this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7888
+ }
6755
7889
  /**
6756
7890
  * Get and clear unconsumed steering messages.
6757
7891
  */
@@ -6784,7 +7918,8 @@ class ChatMessageAccumulator {
6784
7918
  }
6785
7919
  // 2. Pending message injection
6786
7920
  if (pm && queue.length > 0) {
6787
- const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7921
+ const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7922
+ await this.absorbSteering(claimed, injected);
6788
7923
  if (injected.length > 0) {
6789
7924
  resultMessages = [...(resultMessages ?? messages), ...injected];
6790
7925
  }
@@ -6973,7 +8108,7 @@ function trackActiveChatSessionIterator(iterator) {
6973
8108
  * ```
6974
8109
  */
6975
8110
  function createChatSession(payload, options) {
6976
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
8111
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6977
8112
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
6978
8113
  return {
6979
8114
  [Symbol.asyncIterator]() {
@@ -7062,8 +8197,16 @@ function createChatSession(payload, options) {
7062
8197
  * without suspending.
7063
8198
  */
7064
8199
  if (turn > 0) {
8200
+ if (locals.get(chatCloseRequestedKey)) {
8201
+ await performChatClose();
8202
+ stop.cleanup();
8203
+ return { done: true, value: undefined };
8204
+ }
7065
8205
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7066
8206
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
8207
+ if (locals.get(chatUpgradeRequestedKey)) {
8208
+ await persistUpgradeHandoff();
8209
+ }
7067
8210
  stop.cleanup();
7068
8211
  return { done: true, value: undefined };
7069
8212
  }
@@ -7166,6 +8309,7 @@ function createChatSession(payload, options) {
7166
8309
  }
7167
8310
  accumulator.applyHandover(pendingHandoverSignal);
7168
8311
  }
8312
+ await followSessionPin(currentPayload.chatId, sessionVersionSkew);
7169
8313
  // chat.requestUpgrade() called before this turn — signal transport and exit
7170
8314
  if (locals.get(chatUpgradeRequestedKey)) {
7171
8315
  await writeUpgradeRequiredChunk();
@@ -7382,7 +8526,8 @@ function createChatSession(payload, options) {
7382
8526
  }
7383
8527
  }
7384
8528
  if (sessionPendingMessages) {
7385
- const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8529
+ const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8530
+ await accumulator.absorbSteering(claimed, injected);
7386
8531
  if (injected.length > 0) {
7387
8532
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
7388
8533
  }
@@ -7396,6 +8541,11 @@ function createChatSession(payload, options) {
7396
8541
  async return() {
7397
8542
  activeMsgSub?.off();
7398
8543
  activeMsgSub = undefined;
8544
+ // Reached when the consumer leaves the `for await` early (`break`,
8545
+ // `return`, a throw). A `chat.close()` from the loop body would
8546
+ // otherwise be dropped: the exit that performs it lives in `next()`,
8547
+ // and `next()` is never called again.
8548
+ await performChatClose();
7399
8549
  // `stop` only exists once next() has booted the iterator.
7400
8550
  stop?.cleanup();
7401
8551
  return { done: true, value: undefined };
@@ -7652,6 +8802,11 @@ function createChatStartSessionAction(taskId, options) {
7652
8802
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
7653
8803
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
7654
8804
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8805
+ // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8806
+ // action default, which neither truthiness nor `??` would allow.
8807
+ const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8808
+ ? params.triggerConfig.externalDeploymentId
8809
+ : options?.triggerConfig?.externalDeploymentId;
7655
8810
  const triggerConfig = {
7656
8811
  basePayload: {
7657
8812
  messages: [],
@@ -7678,6 +8833,10 @@ function createChatStartSessionAction(taskId, options) {
7678
8833
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
7679
8834
  }
7680
8835
  : {}),
8836
+ ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8837
+ ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8838
+ : {}),
8839
+ ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
7681
8840
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
7682
8841
  };
7683
8842
  const startBody = {
@@ -7722,6 +8881,7 @@ function createChatStartSessionAction(taskId, options) {
7722
8881
  publicAccessToken,
7723
8882
  runId: created.runId,
7724
8883
  sessionId: created.id,
8884
+ ...(created.pendingVersion ? { pendingVersion: true } : {}),
7725
8885
  };
7726
8886
  };
7727
8887
  }
@@ -7755,7 +8915,8 @@ async function callSessionsCreateWithOverride(args) {
7755
8915
  const init = {
7756
8916
  method: "POST",
7757
8917
  headers: overrideRequestHeaders(accessToken),
7758
- body: JSON.stringify(args.body),
8918
+ // This path bypasses `sessions.start`, so it resolves the pin itself.
8919
+ body: JSON.stringify(withResolvedExternalDeploymentId(args.body)),
7759
8920
  };
7760
8921
  const response = args.fetchOverride
7761
8922
  ? await args.fetchOverride(url, init, ctx)
@@ -7820,6 +8981,41 @@ async function mintPublicTokenWithOverride(args) {
7820
8981
  expirationTime: args.expirationTime,
7821
8982
  });
7822
8983
  }
8984
+ /**
8985
+ * Creates a server-side helper that reads a conversation from a transcript
8986
+ * storage, for rendering history before the chat connects. Works the same
8987
+ * for every storage, the platform default included, so the browser never
8988
+ * reads a store directly and the secret key stays on the server.
8989
+ *
8990
+ * Wrap it in a Next.js server action (or any server-side handler), scope it
8991
+ * to the authenticated user through `clientData`, and pass the result to
8992
+ * `useLoadTranscript` in the browser.
8993
+ *
8994
+ * @example
8995
+ * ```ts
8996
+ * // actions.ts
8997
+ * "use server";
8998
+ * import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
8999
+ *
9000
+ * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
9001
+ * ```
9002
+ */
9003
+ function createChatLoadTranscriptAction(storage, options) {
9004
+ return async (params) => {
9005
+ if (!params.chatId) {
9006
+ throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
9007
+ }
9008
+ if (options?.apiClient) {
9009
+ const { apiClient, ...rest } = options;
9010
+ return apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
9011
+ }
9012
+ const limit = params.limit ?? options?.limit;
9013
+ return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
9014
+ ...(limit !== undefined ? { limit } : {}),
9015
+ ...(params.before !== undefined ? { before: params.before } : {}),
9016
+ });
9017
+ };
9018
+ }
7823
9019
  export const chat = {
7824
9020
  /** Create a chat agent. See {@link chatAgent}. */
7825
9021
  agent: chatAgent,
@@ -7831,8 +9027,12 @@ export const chat = {
7831
9027
  withClientData,
7832
9028
  /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
7833
9029
  createStartSessionAction: createChatStartSessionAction,
9030
+ /** Returns a server-side helper that reads a conversation from a transcript storage. */
9031
+ createLoadTranscriptAction: createChatLoadTranscriptAction,
7834
9032
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
7835
9033
  pipe: pipeChat,
9034
+ /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
9035
+ turn: chatTurn,
7836
9036
  /** Create a per-run typed local. See {@link chatLocal}. */
7837
9037
  local: chatLocal,
7838
9038
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -7853,10 +9053,13 @@ export const chat = {
7853
9053
  endAndContinue,
7854
9054
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
7855
9055
  endRun,
9056
+ /** End the conversation permanently: close the session and exit the run. See {@link close}. */
9057
+ close,
7856
9058
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
7857
9059
  cleanupAbortedParts,
7858
9060
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
7859
9061
  defer: chatDefer,
9062
+ deferBeforeOutput: chatDeferBeforeOutput,
7860
9063
  /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */
7861
9064
  inject: injectBackgroundContext,
7862
9065
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */
@@ -8000,6 +9203,16 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8000
9203
  if (consumedCursor !== undefined) {
8001
9204
  extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8002
9205
  }
9206
+ // A close decided before this turn ended rides out on turn-complete. Readers
9207
+ // terminate their stream on turn-complete, so a standalone record written
9208
+ // after it only reaches a reconnect — this header is what a live client sees.
9209
+ const pendingClose = locals.get(chatCloseRequestedKey);
9210
+ if (pendingClose) {
9211
+ extraHeaders.push([SESSION_CLOSED_HEADER, "true"]);
9212
+ if (pendingClose.reason) {
9213
+ extraHeaders.push([SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
9214
+ }
9215
+ }
8003
9216
  const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8004
9217
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8005
9218
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8057,12 +9270,47 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8057
9270
  *
8058
9271
  * @internal
8059
9272
  */
9273
+ /**
9274
+ * Persists an upgrade requested after the turn has already run.
9275
+ *
9276
+ * The pre-turn sites reach {@link performEndAndContinue} through
9277
+ * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
9278
+ * session's stored `externalDeploymentId`. The post-turn exits had no such path,
9279
+ * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
9280
+ * and every continuation re-pinned to the deployment the agent asked to leave.
9281
+ *
9282
+ * No `upgrade-required` chunk is written here: the turn already produced its
9283
+ * answer, so there is nothing for a client to be told about.
9284
+ */
9285
+ async function persistUpgradeHandoff() {
9286
+ const chatId = locals.get(chatExternalIdKey);
9287
+ const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
9288
+ if (!chatId || !callingRunId) {
9289
+ return;
9290
+ }
9291
+ try {
9292
+ await performEndAndContinue({
9293
+ reason: "upgrade",
9294
+ externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9295
+ });
9296
+ }
9297
+ catch (error) {
9298
+ logger.warn("upgrade handoff failed; session keeps its current version pin", {
9299
+ chatId,
9300
+ callingRunId,
9301
+ error,
9302
+ });
9303
+ }
9304
+ }
8060
9305
  async function writeUpgradeRequiredChunk() {
8061
9306
  const chatId = locals.get(chatExternalIdKey);
8062
9307
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
8063
9308
  if (chatId && callingRunId) {
8064
9309
  try {
8065
- await performEndAndContinue();
9310
+ await performEndAndContinue({
9311
+ reason: "upgrade",
9312
+ externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9313
+ });
8066
9314
  }
8067
9315
  catch (error) {
8068
9316
  // Non-fatal: the next `.in/append` re-triggers via the probe.