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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +0 -2
  3. package/dist/commonjs/v3/ai-shared.d.ts +0 -15
  4. package/dist/commonjs/v3/ai-shared.js +1 -35
  5. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  6. package/dist/commonjs/v3/ai.d.ts +25 -275
  7. package/dist/commonjs/v3/ai.js +308 -1283
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/chat-client.d.ts +2 -11
  10. package/dist/commonjs/v3/chat-client.js +111 -227
  11. package/dist/commonjs/v3/chat-client.js.map +1 -1
  12. package/dist/commonjs/v3/chat-react.d.ts +0 -88
  13. package/dist/commonjs/v3/chat-react.js +1 -130
  14. package/dist/commonjs/v3/chat-react.js.map +1 -1
  15. package/dist/commonjs/v3/chat-server.d.ts +6 -42
  16. package/dist/commonjs/v3/chat-server.js +7 -52
  17. package/dist/commonjs/v3/chat-server.js.map +1 -1
  18. package/dist/commonjs/v3/chat.d.ts +10 -92
  19. package/dist/commonjs/v3/chat.js +73 -328
  20. package/dist/commonjs/v3/chat.js.map +1 -1
  21. package/dist/commonjs/v3/sessions.d.ts +2 -15
  22. package/dist/commonjs/v3/sessions.js +1 -12
  23. package/dist/commonjs/v3/sessions.js.map +1 -1
  24. package/dist/commonjs/v3/shared.js +36 -30
  25. package/dist/commonjs/v3/shared.js.map +1 -1
  26. package/dist/commonjs/v3/test/index.d.ts +0 -1
  27. package/dist/commonjs/v3/test/index.js +1 -3
  28. package/dist/commonjs/v3/test/index.js.map +1 -1
  29. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +4 -47
  30. package/dist/commonjs/v3/test/mock-chat-agent.js +4 -115
  31. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  32. package/dist/commonjs/v3/test/test-session-handle.js +0 -6
  33. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  34. package/dist/commonjs/version.js +1 -1
  35. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  36. package/dist/esm/imports/ai-runtime.js +2 -2
  37. package/dist/esm/imports/ai-runtime.js.map +1 -1
  38. package/dist/esm/v3/ai-shared.d.ts +0 -15
  39. package/dist/esm/v3/ai-shared.js +0 -33
  40. package/dist/esm/v3/ai-shared.js.map +1 -1
  41. package/dist/esm/v3/ai.d.ts +25 -275
  42. package/dist/esm/v3/ai.js +305 -1275
  43. package/dist/esm/v3/ai.js.map +1 -1
  44. package/dist/esm/v3/chat-client.d.ts +2 -11
  45. package/dist/esm/v3/chat-client.js +112 -228
  46. package/dist/esm/v3/chat-client.js.map +1 -1
  47. package/dist/esm/v3/chat-react.d.ts +0 -88
  48. package/dist/esm/v3/chat-react.js +1 -127
  49. package/dist/esm/v3/chat-react.js.map +1 -1
  50. package/dist/esm/v3/chat-server.d.ts +6 -42
  51. package/dist/esm/v3/chat-server.js +8 -53
  52. package/dist/esm/v3/chat-server.js.map +1 -1
  53. package/dist/esm/v3/chat.d.ts +10 -92
  54. package/dist/esm/v3/chat.js +74 -329
  55. package/dist/esm/v3/chat.js.map +1 -1
  56. package/dist/esm/v3/sessions.d.ts +2 -15
  57. package/dist/esm/v3/sessions.js +1 -11
  58. package/dist/esm/v3/sessions.js.map +1 -1
  59. package/dist/esm/v3/shared.js +23 -17
  60. package/dist/esm/v3/shared.js.map +1 -1
  61. package/dist/esm/v3/test/index.d.ts +0 -1
  62. package/dist/esm/v3/test/index.js +0 -1
  63. package/dist/esm/v3/test/index.js.map +1 -1
  64. package/dist/esm/v3/test/mock-chat-agent.d.ts +4 -47
  65. package/dist/esm/v3/test/mock-chat-agent.js +6 -117
  66. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  67. package/dist/esm/v3/test/test-session-handle.js +0 -6
  68. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  69. package/dist/esm/version.js +1 -1
  70. package/docs/ai-chat/actions.mdx +23 -55
  71. package/docs/ai-chat/anatomy.mdx +3 -3
  72. package/docs/ai-chat/backend.mdx +48 -125
  73. package/docs/ai-chat/background-injection.mdx +19 -69
  74. package/docs/ai-chat/client-protocol.mdx +4 -5
  75. package/docs/ai-chat/compaction.mdx +7 -13
  76. package/docs/ai-chat/custom-agents.mdx +0 -23
  77. package/docs/ai-chat/fast-starts.mdx +20 -27
  78. package/docs/ai-chat/frontend.mdx +14 -19
  79. package/docs/ai-chat/lifecycle-hooks.mdx +2 -6
  80. package/docs/ai-chat/migrating-from-a-route-handler.mdx +14 -16
  81. package/docs/ai-chat/patterns/database-persistence.mdx +2 -6
  82. package/docs/ai-chat/patterns/persistence-and-replay.mdx +23 -34
  83. package/docs/ai-chat/patterns/skills.mdx +10 -7
  84. package/docs/ai-chat/patterns/version-upgrades.mdx +6 -79
  85. package/docs/ai-chat/pending-messages.mdx +3 -3
  86. package/docs/ai-chat/prompt-caching.mdx +25 -23
  87. package/docs/ai-chat/quick-start.mdx +11 -11
  88. package/docs/ai-chat/reference.mdx +9 -67
  89. package/docs/ai-chat/sessions.mdx +1 -6
  90. package/docs/ai-chat/testing.mdx +1 -2
  91. package/docs/ai-chat/tools.mdx +13 -18
  92. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  93. package/docs/apikeys.mdx +45 -27
  94. package/docs/config/extensions/syncEnvVars.mdx +0 -6
  95. package/docs/deploy-environment-variables.mdx +2 -23
  96. package/docs/deployment/overview.mdx +8 -4
  97. package/docs/deployment/preview-branches.mdx +4 -4
  98. package/docs/deployment/version-skew-protection.mdx +0 -62
  99. package/docs/manual-setup.mdx +7 -7
  100. package/docs/mcp-tools.mdx +0 -9
  101. package/docs/quick-start.mdx +3 -3
  102. package/docs/realtime/auth.mdx +1 -1
  103. package/docs/self-hosting/security.mdx +0 -5
  104. package/docs/tasks/scheduled.mdx +0 -24
  105. package/docs/triggering.mdx +1 -1
  106. package/package.json +4 -4
  107. package/skills/trigger-authoring-chat-agent/SKILL.md +29 -41
  108. package/skills/trigger-chat-agent-advanced/SKILL.md +12 -31
  109. package/dist/commonjs/v3/chatSnapshotIo.d.ts +0 -2
  110. package/dist/commonjs/v3/chatSnapshotIo.js +0 -174
  111. package/dist/commonjs/v3/chatSnapshotIo.js.map +0 -1
  112. package/dist/commonjs/v3/chatVersionSkew.d.ts +0 -12
  113. package/dist/commonjs/v3/chatVersionSkew.js +0 -30
  114. package/dist/commonjs/v3/chatVersionSkew.js.map +0 -1
  115. package/dist/commonjs/v3/externalDeploymentId.d.ts +0 -23
  116. package/dist/commonjs/v3/externalDeploymentId.js +0 -43
  117. package/dist/commonjs/v3/externalDeploymentId.js.map +0 -1
  118. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +0 -40
  119. package/dist/commonjs/v3/test/transcript-storage-tests.js +0 -258
  120. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +0 -1
  121. package/dist/commonjs/v3/transcriptStorage.d.ts +0 -229
  122. package/dist/commonjs/v3/transcriptStorage.js +0 -321
  123. package/dist/commonjs/v3/transcriptStorage.js.map +0 -1
  124. package/dist/esm/v3/chatSnapshotIo.d.ts +0 -2
  125. package/dist/esm/v3/chatSnapshotIo.js +0 -166
  126. package/dist/esm/v3/chatSnapshotIo.js.map +0 -1
  127. package/dist/esm/v3/chatVersionSkew.d.ts +0 -12
  128. package/dist/esm/v3/chatVersionSkew.js +0 -27
  129. package/dist/esm/v3/chatVersionSkew.js.map +0 -1
  130. package/dist/esm/v3/externalDeploymentId.d.ts +0 -23
  131. package/dist/esm/v3/externalDeploymentId.js +0 -38
  132. package/dist/esm/v3/externalDeploymentId.js.map +0 -1
  133. package/dist/esm/v3/test/transcript-storage-tests.d.ts +0 -40
  134. package/dist/esm/v3/test/transcript-storage-tests.js +0 -255
  135. package/dist/esm/v3/test/transcript-storage-tests.js.map +0 -1
  136. package/dist/esm/v3/transcriptStorage.d.ts +0 -229
  137. package/dist/esm/v3/transcriptStorage.js +0 -309
  138. package/dist/esm/v3/transcriptStorage.js.map +0 -1
  139. package/docs/ai-chat/patterns/native-compaction.mdx +0 -310
  140. package/docs/ai-chat/transcript-storage.mdx +0 -233
  141. package/docs/reports.mdx +0 -157
  142. package/docs/troubleshooting-zod.mdx +0 -158
package/dist/esm/v3/ai.js CHANGED
@@ -1,18 +1,8 @@
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";
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";
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, streamText as aiStreamText, zodSchema, } from "../imports/ai-runtime.js";
6
- import { createTranscriptShadow, defaultStorage, diffTranscript, parseTranscriptRuntimeState, prefixFingerprint, 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
- }
5
+ import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, zodSchema, } from "../imports/ai-runtime.js";
16
6
  import { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage, chatRunTags, } from "./ai-shared.js";
17
7
  import { auth } from "./auth.js";
18
8
  import { locals } from "./locals.js";
@@ -27,8 +17,6 @@ import { metadata } from "./metadata.js";
27
17
  // pulled in transitively here never reach a client chunk.
28
18
  import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js";
29
19
  import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
30
- import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
31
- import { resolvePinToFollow } from "./chatVersionSkew.js";
32
20
  import { sessions, } from "./sessions.js";
33
21
  import { createTask } from "./shared.js";
34
22
  import { markChatAgentRunForStreamsWarning } from "./streams.js";
@@ -115,8 +103,182 @@ async function findLatestSessionInCursor(chatId) {
115
103
  }
116
104
  return latestCursor;
117
105
  }
118
- export { __readChatSnapshotProductionPathForTests, __setReadChatSnapshotImplForTests, __setWriteChatSnapshotImplForTests, __writeChatSnapshotProductionPathForTests, } from "./chatSnapshotIo.js";
119
- export { defaultStorage, memoryTranscriptStorage, reduceTranscriptChanges, snapshotTranscriptStorage, } from "./transcriptStorage.js";
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
+ }
120
282
  /**
121
283
  * Merge two `UIMessage[]` lists by `id`, with the second list winning on
122
284
  * collision. Used at run boot to combine the snapshot's persisted history
@@ -1592,13 +1754,6 @@ async function installChatInputRouter(chatId, options) {
1592
1754
  checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1593
1755
  }
1594
1756
  }
1595
- // A boot that replayed `.in` itself has already answered everything up to
1596
- // `recoveredThrough`, so the floor has to cover it before the tail opens.
1597
- if (options?.recoveredThrough !== undefined) {
1598
- const recovered = options.recoveredThrough;
1599
- checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
1600
- checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? checkpoint.resumeFrom, checkpoint.resumeFrom);
1601
- }
1602
1757
  const router = entry.router;
1603
1758
  router.restore(checkpoint);
1604
1759
  const floor = router.resumeFrom();
@@ -1607,10 +1762,6 @@ async function installChatInputRouter(chatId, options) {
1607
1762
  sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
1608
1763
  }
1609
1764
  sessionStreams.onRecord(chatId, "in", (record) => {
1610
- // The floor is the tail's `Last-Event-ID`, but a reconnect can still
1611
- // re-deliver below it and a replayable route would re-queue it.
1612
- if (floor !== undefined && record.seqNum <= floor)
1613
- return true;
1614
1765
  router.ingest(record);
1615
1766
  return true;
1616
1767
  });
@@ -1782,35 +1933,6 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
1782
1933
  * @internal
1783
1934
  */
1784
1935
  const chatBackgroundQueueKey = locals.create("chat.backgroundQueue");
1785
- /**
1786
- * Background injections a step-boundary drain handed to the model this turn,
1787
- * with the transcript message they followed. Reconciled into the model lane
1788
- * and the persisted injections once the turn's response is in.
1789
- */
1790
- const chatPendingBackgroundKey = locals.create("chat.pendingBackground");
1791
- /**
1792
- * System-role context injected mid-conversation, held for the instructions lane.
1793
- *
1794
- * Kept apart from the message queue because ai@7 rejects a system message inside
1795
- * `messages` for every provider — `standardizePrompt` throws upstream of any
1796
- * provider call, and its own advice is to use the instructions option. Instructions
1797
- * accept `Array<SystemModelMessage>`, so a system-role injection has a correct
1798
- * home: appended as another system block rather than smuggled into the transcript.
1799
- *
1800
- * This is also the only way to inject *trusted* context. A message injected as
1801
- * `user` is untrusted by construction, and a well-aligned model treats it that
1802
- * way — it will say so, and re-derive the answer from tools instead.
1803
- */
1804
- const chatInjectedInstructionsKey = locals.create("chat.injectedInstructions");
1805
- /**
1806
- * What a turn already consumed from the instructions lane, so a second
1807
- * `toStreamTextOptions()` call in the same turn sees the same blocks.
1808
- *
1809
- * Consumed blocks are moved here rather than left in the pending lane: leaving
1810
- * them there means an injection made during the consumed turn sits behind them,
1811
- * and clearing the lane on the next turn destroys both.
1812
- */
1813
- const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsConsumed");
1814
1936
  /**
1815
1937
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
1816
1938
  * same worker don't share state.
@@ -2321,31 +2443,12 @@ const chatToolsOptionKey = locals.create("chat.toolsOption");
2321
2443
  const chatResolvedToolsKey = locals.create("chat.resolvedTools");
2322
2444
  /** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
2323
2445
  const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested");
2324
- /** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
2325
- const chatUpgradeExternalDeploymentIdKey = locals.create("chat.upgradeExternalDeploymentId");
2326
2446
  /**
2327
2447
  * @internal Flag set by `chat.endRun()` to exit the loop after the current
2328
2448
  * turn completes, without any upgrade semantics. Checked at the same
2329
2449
  * post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
2330
2450
  */
2331
2451
  const chatEndRunRequestedKey = locals.create("chat.endRunRequested");
2332
- /**
2333
- * @internal Set by `chat.close()`. Holds the close request (and its reason)
2334
- * for the rest of the run: the loop writes the terminal `session-closed`
2335
- * record, closes the session row, and exits at the same post-turn /
2336
- * pre-wait sites as `chatEndRunRequestedKey`.
2337
- */
2338
- const chatCloseRequestedKey = locals.create("chat.closeRequested");
2339
- /** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
2340
- const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
2341
- /** @internal Set once the session row is closed, so the close happens once. */
2342
- const chatClosePerformedKey = locals.create("chat.closePerformed");
2343
- /**
2344
- * @internal Set once the terminal `.out` record is written. Tracked apart from
2345
- * {@link chatClosePerformedKey} so a retried close does not emit a second
2346
- * client-visible event.
2347
- */
2348
- const chatCloseRecordWrittenKey = locals.create("chat.closeRecordWritten");
2349
2452
  /** @internal */
2350
2453
  const chatAgentCompactionKey = locals.create("chat.agentCompaction");
2351
2454
  /**
@@ -2362,32 +2465,6 @@ export { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage };
2362
2465
  const chatPendingMessagesKey = locals.create("chat.pendingMessages");
2363
2466
  /** @internal */
2364
2467
  const chatSteeringQueueKey = locals.create("chat.steeringQueue");
2365
- /**
2366
- * This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
2367
- *
2368
- * Held in locals because `drainSteeringQueue` runs outside the turn closure and
2369
- * has to append the messages it injects. Without that, an injected message
2370
- * reaches the model and the browser but no hook, so an app persisting from
2371
- * `onTurnComplete` never learns it existed.
2372
- */
2373
- const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages");
2374
- /**
2375
- * Steering messages a drain consumed that the model accumulator has not been
2376
- * given yet.
2377
- *
2378
- * The two accumulators are maintained separately, and the model one is
2379
- * normally advanced by appending each turn's delta. A drained message is
2380
- * appended to the UI one but reaches the model only through the `prepareStep`
2381
- * return value, which is per-step: without this the model lane never learns
2382
- * the message exists and every later turn of the run answers without it,
2383
- * while the browser, the snapshot and `chat.history.*` all still show it.
2384
- *
2385
- * Held as the messages rather than a "rebuild me" flag because the model lane
2386
- * can only be appended to, never reconstructed. Compaction replaces it with a
2387
- * summary and deliberately leaves the UI lane whole, so reconverting the UI
2388
- * lane restores every message the summary replaced.
2389
- */
2390
- const chatPendingSteerKey = locals.create("chat.pendingSteer");
2391
2468
  /** @internal — IDs of messages that were successfully injected via prepareStep */
2392
2469
  const chatInjectedMessageIdsKey = locals.create("chat.injectedMessageIds");
2393
2470
  /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -2740,32 +2817,20 @@ function chatCompactionStep(options) {
2740
2817
  return result.type === "skipped" ? undefined : result;
2741
2818
  };
2742
2819
  }
2743
- const EMPTY_DRAIN = { injected: [], claimed: [] };
2744
- /**
2745
- * The model messages to record for one claimed message. Without `prepare`
2746
- * each entry's own conversion is used. With it, `prepare` returned one list
2747
- * for the whole batch, so the first claimed message carries all of it and the
2748
- * rest carry none, which keeps the total exactly what the model received.
2749
- */
2750
- function modelFormOf(m, batch, injected) {
2751
- return batch[0] === m ? injected : [];
2752
- }
2820
+ // ---------------------------------------------------------------------------
2821
+ // Steering queue drain — shared by toStreamTextOptions, session, accumulator
2822
+ // ---------------------------------------------------------------------------
2753
2823
  /**
2754
2824
  * Drain the steering queue as a batch. Calls `shouldInject` once with all
2755
2825
  * pending messages. If it returns true, calls `prepareMessages` once to
2756
2826
  * transform the batch, then clears the queue.
2757
- * Returns the model messages to inject and the UI messages actually claimed.
2758
- *
2759
- * `claimed` is returned rather than only published to locals because each
2760
- * surface files it somewhere different: `chat.agent` has an accumulator in
2761
- * locals, while `chat.createSession` keeps its own. Publishing to locals alone
2762
- * is silently a no-op for any surface that never set the key.
2827
+ * Returns the model messages to inject (empty if none).
2763
2828
  * @internal
2764
2829
  */
2765
2830
  async function drainSteeringQueue(config, messages, steps, queueOverride) {
2766
2831
  const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
2767
2832
  if (!queue || queue.length === 0)
2768
- return EMPTY_DRAIN;
2833
+ return [];
2769
2834
  const ctx = locals.get(chatTurnContextKey);
2770
2835
  const stepNumber = steps.length - 1;
2771
2836
  /**
@@ -2788,7 +2853,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2788
2853
  // Call shouldInject once for the whole batch
2789
2854
  const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
2790
2855
  if (!shouldInject)
2791
- return EMPTY_DRAIN;
2856
+ return [];
2792
2857
  const textOfUIMessage = (m) => (m.parts ?? [])
2793
2858
  .filter((p) => p.type === "text")
2794
2859
  .map((p) => p.text)
@@ -2834,7 +2899,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2834
2899
  queue.splice(at, 1);
2835
2900
  }
2836
2901
  if (claimed.length === 0)
2837
- return EMPTY_DRAIN;
2902
+ return [];
2838
2903
  /**
2839
2904
  * Give the claim back if the transform fails. `prepare` is caller code and
2840
2905
  * can throw; the records have already left the router by this point, so
@@ -2864,37 +2929,6 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2864
2929
  for (const m of claimedUIMessages)
2865
2930
  injectedIds.add(m.id);
2866
2931
  }
2867
- // Record them as part of the conversation.
2868
- //
2869
- // The model has them and the browser has them; without this the
2870
- // accumulator does not, so they reach neither `uiMessages` nor
2871
- // `newUIMessages` on `onTurnComplete` and an app that persists from there
2872
- // silently loses the instruction the answer was shaped by. Appending here
2873
- // rather than at turn end keeps them in the order they happened: after the
2874
- // message that started the turn, before the response that answers it.
2875
- //
2876
- // De-duplicated by id because a step boundary can drain more than once per
2877
- // turn, and because a message that failed to inject falls back to becoming
2878
- // its own turn, where it is accumulated the normal way.
2879
- const currentUIMessages = locals.get(chatCurrentUIMessagesKey);
2880
- const turnNew = locals.get(chatTurnNewUIMessagesKey);
2881
- for (const m of claimedUIMessages) {
2882
- if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
2883
- currentUIMessages.push(m);
2884
- }
2885
- if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
2886
- turnNew.push(m);
2887
- }
2888
- }
2889
- if (claimedUIMessages.length > 0 && currentUIMessages) {
2890
- const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
2891
- for (const m of claimedUIMessages) {
2892
- if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
2893
- pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
2894
- }
2895
- }
2896
- locals.set(chatPendingSteerKey, pendingSteer);
2897
- }
2898
2932
  // Write injection confirmation chunk to the stream so the frontend
2899
2933
  // knows which messages were injected and where in the response.
2900
2934
  if (injected.length > 0) {
@@ -2936,7 +2970,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2936
2970
  /* non-fatal */
2937
2971
  }
2938
2972
  }
2939
- return { injected, claimed: claimedUIMessages };
2973
+ return injected;
2940
2974
  }, {
2941
2975
  attributes: {
2942
2976
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
@@ -3162,149 +3196,32 @@ export function buildSkillTools(skills) {
3162
3196
  return { loadSkill, readFile, bash };
3163
3197
  }
3164
3198
  /**
3165
- * A `streamText` with the agent's managed options already applied.
3166
- *
3167
- * Handed to `run()` so the managed state cannot be missed by omission. Spreading
3168
- * `chat.toStreamTextOptions()` is still supported and equivalent; this exists
3169
- * because forgetting the spread silently drops the managed prompt, the skill
3170
- * tools, telemetry, and the `prepareStep` that delivers steering, compaction and
3171
- * conversational injection.
3172
- *
3173
- * Caller options win for everything the caller owns (model, messages, signal,
3174
- * stopWhen). The three that would otherwise clobber managed behaviour are
3175
- * merged instead of replaced:
3176
- *
3177
- * - `tools` are passed into the helper, so skill tools survive.
3178
- * - `prepareStep` is composed after the managed one, so a caller's per-step
3179
- * overrides apply on top of steering and compaction instead of disabling them.
3180
- *
3181
- * `system` may be set at the call site, on `chat.agent({ system })`, or
3182
- * through `chat.prompt.set()`, but only in one of them. Setting it in two
3183
- * places throws: no shape merges two system values on every supported
3184
- * version, and dropping one silently is the failure this seam exists to
3185
- * prevent. Injected instructions append to whichever one is in play.
3186
- */
3187
- /**
3188
- * The agent-level managed options (`registry`, `system`, `cacheControl`,
3189
- * `systemProviderOptions`), published for the run so that
3190
- * `chat.toStreamTextOptions()` applies them too. Without this only the bound
3191
- * `streamText` saw them, and the documented spread form silently ran without
3192
- * the agent's system prompt or model.
3193
- */
3194
- const chatAgentManagedConfigKey = locals.create("chat.agentManagedConfig");
3195
- /**
3196
- * The caller's `streamText` options merged with the agent's managed ones.
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`.
3197
3206
  *
3198
- * Pure, and separate from the call so it can be asserted directly: everything
3199
- * the caller did not name has to survive the merge, and the way to be sure of
3200
- * that is to look at the merged object rather than at what the model received.
3207
+ * If no prompt has been set, returns `{}` (no-op spread).
3201
3208
  */
3202
- function buildManagedStreamTextOptions(options, config) {
3203
- const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
3204
- /**
3205
- * Only the three keys that collide are intercepted. Everything else, telemetry
3206
- * included, stays in `rest` and reaches `streamText` untouched, with the
3207
- * caller's value winning because `rest` is spread after `managed`. Pulling a
3208
- * key out to "handle" it is how a caller's option gets silently dropped.
3209
- */
3210
- const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3211
- const managed = toStreamTextOptions({
3212
- registry,
3213
- system: callerSystem ?? agentSystem,
3214
- cacheControl,
3215
- systemProviderOptions,
3216
- /**
3217
- * A call site that names `tools` replaces the agent's set rather than
3218
- * adding to it, so narrowing the tools for one call still works. Omitting
3219
- * `tools` falls back to the agent's, which is what an `onAction`
3220
- * regenerate needs: without it a regenerated answer can call nothing.
3221
- */
3222
- tools: (tools ?? agentTools),
3223
- });
3224
- const promptSystem = locals.get(chatPromptKey)?.text;
3225
- /**
3226
- * Two managed sources conflict too, not only a caller against a managed one.
3227
- * `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
3228
- * would win and `chat.agent({ system })` would go nowhere.
3229
- */
3230
- if (promptSystem && agentSystem) {
3231
- throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
3232
- "of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
3233
- "place, and add per-turn context with chat.inject({ role: 'system' }).");
3234
- }
3235
- const managedSystem = promptSystem || agentSystem;
3236
- if (callerSystem !== undefined && managedSystem) {
3237
- throw new Error("chat.agent: `system` is already set " +
3238
- (promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
3239
- ", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
3240
- "per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
3241
- }
3242
- const managedPrepareStep = managed.prepareStep;
3243
- if (typeof callerPrepareStep === "function") {
3244
- managed.prepareStep = async (arg) => {
3245
- const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
3246
- const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
3247
- return { ...(first ?? {}), ...(second ?? {}) };
3248
- };
3249
- }
3250
- return { ...managed, ...rest };
3251
- }
3252
- /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
3253
- export const __buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
3254
- function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
3255
- const bound = (options = {}) => aiStreamText(buildManagedStreamTextOptions(options, {
3256
- registry,
3257
- system: agentSystem,
3258
- cacheControl: agentCacheControl,
3259
- systemProviderOptions: agentSystemProviderOptions,
3260
- /** Read per call, so per-turn tools resolved after binding are included. */
3261
- tools: locals.get(chatResolvedToolsKey),
3262
- }));
3263
- return bound;
3264
- }
3265
3209
  function toStreamTextOptions(options) {
3266
- const agentDefaults = locals.get(chatAgentManagedConfigKey);
3267
- if (agentDefaults) {
3268
- options = {
3269
- registry: agentDefaults.registry,
3270
- system: agentDefaults.system,
3271
- cacheControl: agentDefaults.cacheControl,
3272
- systemProviderOptions: agentDefaults.systemProviderOptions,
3273
- ...options,
3274
- };
3275
- }
3276
3210
  const prompt = locals.get(chatPromptKey);
3277
3211
  const skills = locals.get(chatSkillsKey);
3278
3212
  const result = {};
3279
3213
  // Build the combined system prompt: stored prompt + skills preamble.
3280
- const baseSystem = options?.system;
3281
- const baseSystemText = typeof baseSystem === "string"
3282
- ? baseSystem
3283
- : typeof baseSystem?.content === "string"
3284
- ? baseSystem.content
3285
- : "";
3286
- const promptText = prompt?.text || baseSystemText;
3214
+ const promptText = prompt?.text ?? "";
3287
3215
  const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
3288
3216
  if (promptText || skillsText) {
3289
3217
  const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
3290
- /**
3291
- * Resolve system-prompt provider options for caching. Precedence, most
3292
- * specific first and no deep merge: explicit `systemProviderOptions`, the
3293
- * `cacheControl` sugar, the ones carried on a structured `system` message,
3294
- * then whatever `chat.prompt.set()` stored.
3295
- *
3296
- * A structured `system` counts only when its own text is the one being
3297
- * sent. When `chat.prompt.set()` supplied the text, its provider options
3298
- * are the ones that describe it.
3299
- */
3300
- const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
3301
- ? baseSystem.providerOptions
3302
- : undefined;
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()`.
3303
3221
  const systemProviderOptions = options?.systemProviderOptions ??
3304
3222
  (options?.cacheControl
3305
3223
  ? { anthropic: { cacheControl: options.cacheControl } }
3306
3224
  : undefined) ??
3307
- baseSystemProviderOptions ??
3308
3225
  locals.get(chatPromptProviderOptionsKey);
3309
3226
  // A bare string stays a bare string (the unchanged default). With provider
3310
3227
  // options, emit a structured `SystemModelMessage` so the provider can cache
@@ -3313,88 +3230,6 @@ function toStreamTextOptions(options) {
3313
3230
  ? { role: "system", content: systemText, providerOptions: systemProviderOptions }
3314
3231
  : systemText;
3315
3232
  }
3316
- /**
3317
- * Append anything injected as system context, in whichever shape the installed
3318
- * AI SDK accepts.
3319
- *
3320
- * `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
3321
- * `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
3322
- * range still spans all three, so emitting an array unconditionally would break
3323
- * v5 consumers — for whom a system-role injection used to work, since v5 accepted
3324
- * a system message inside `messages` that v7 rejects.
3325
- *
3326
- * So: concatenate into one string when the base is a plain string, which every
3327
- * version accepts and which loses nothing (separate blocks only matter for
3328
- * per-block `providerOptions`). Use the array form only when the base is already
3329
- * a structured message — that path requires v6+ regardless, because it is how
3330
- * prompt caching marks the system block, and flattening it would silently throw
3331
- * the cache away.
3332
- *
3333
- * Either way the injected text goes last: the base prompt keeps its position for
3334
- * caching, and the addition reads as a later amendment. A changed prefix does
3335
- * cost the first call its cache hit, on turns that actually injected.
3336
- */
3337
- /**
3338
- * Consumed once per turn, not once per read, and moved out of the lane rather
3339
- * than marked read in place.
3340
- *
3341
- * Per turn, because a `run()` that builds options twice (a cheap classifier
3342
- * pass and then the answer) has to see the injection in both, and draining on
3343
- * read hands it to whichever call ran first. Moved out, because blocks left in
3344
- * the lane sit in front of anything injected during the same turn, and
3345
- * clearing the lane on the next turn then destroys both. Outside a turn there
3346
- * is no turn to scope the stash to, so the lane drains on read there.
3347
- */
3348
- const injectedInstructions = locals.get(chatInjectedInstructionsKey);
3349
- const currentTurn = locals.get(chatTurnContextKey)?.turn;
3350
- const consumedThisTurn = currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
3351
- let injectedBlocks = [];
3352
- if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
3353
- injectedBlocks = consumedThisTurn.blocks;
3354
- // Anything injected since the stash was taken joins it, so an instruction
3355
- // added after an action read the lane still reaches the real turn that
3356
- // shares the action's turn number, rather than the one after.
3357
- if (injectedInstructions && injectedInstructions.length > 0) {
3358
- injectedBlocks.push(...injectedInstructions.splice(0));
3359
- }
3360
- }
3361
- else if (injectedInstructions && injectedInstructions.length > 0) {
3362
- injectedBlocks = injectedInstructions.splice(0);
3363
- if (currentTurn !== undefined) {
3364
- locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
3365
- }
3366
- }
3367
- if (injectedBlocks.length > 0) {
3368
- const blocks = injectedBlocks;
3369
- const injectedText = blocks
3370
- .map((block) => (typeof block.content === "string" ? block.content : ""))
3371
- .filter(Boolean)
3372
- .join("\n\n");
3373
- const base = result.system;
3374
- if (base === undefined) {
3375
- result.system = injectedText;
3376
- }
3377
- else if (typeof base === "string") {
3378
- result.system = [base, injectedText].filter(Boolean).join("\n\n");
3379
- }
3380
- else {
3381
- // Merged into the existing block rather than added as a second one. An array
3382
- // of system blocks would keep the base block's cache entry, but ai@5 rejects
3383
- // it outright ("Invalid prompt: system must be a string") while accepting a
3384
- // single structured block, and this package's peer range still spans v5.
3385
- // Choosing per version would mean resolving the installed version at runtime,
3386
- // which is not something to build on: `import.meta.url` is illegal in this
3387
- // package's CommonJS output, and a bundled task may have no resolvable `ai`
3388
- // to read. One shape that works everywhere beats a cache hit.
3389
- const baseBlock = base;
3390
- result.system = {
3391
- ...baseBlock,
3392
- content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
3393
- .filter(Boolean)
3394
- .join("\n\n"),
3395
- };
3396
- }
3397
- }
3398
3233
  // Prompt-related options (only if chat.prompt.set() was called)
3399
3234
  if (prompt) {
3400
3235
  // Resolve model via registry if both are present
@@ -3449,7 +3284,7 @@ function toStreamTextOptions(options) {
3449
3284
  }
3450
3285
  // 2. Pending message injection (steering)
3451
3286
  if (taskPendingMessages) {
3452
- const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3287
+ const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
3453
3288
  if (injected.length > 0) {
3454
3289
  resultMessages = [...(resultMessages ?? messages), ...injected];
3455
3290
  }
@@ -3459,89 +3294,18 @@ function toStreamTextOptions(options) {
3459
3294
  if (bgQueue && bgQueue.length > 0) {
3460
3295
  const injected = bgQueue.splice(0); // drain
3461
3296
  resultMessages = [...(resultMessages ?? messages), ...injected];
3462
- const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
3463
- pendingBackground.push({
3464
- afterId: locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
3465
- messages: injected,
3466
- });
3467
- locals.set(chatPendingBackgroundKey, pendingBackground);
3468
3297
  }
3469
3298
  return resultMessages ? { messages: resultMessages } : undefined;
3470
3299
  };
3471
3300
  }
3472
3301
  return result;
3473
3302
  }
3474
- const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3475
- /**
3476
- * Turn the current action into a turn.
3477
- *
3478
- * Return it from `onAction` after editing history. The action's own work is
3479
- * finished first (the edit is applied and snapshotted), then a turn runs on the
3480
- * result exactly as a message turn does: `onTurnStart`, `run()` with the edited
3481
- * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
3482
- * advances. That gives the answer everything a turn has, the system prompt,
3483
- * tools, steering, compaction, injected instructions and persistence, with no
3484
- * action-specific handling.
3485
- *
3486
- * @example
3487
- * ```ts
3488
- * onAction: async ({ action }) => {
3489
- * if (action.type === "regenerate") {
3490
- * chat.history.slice(0, -1);
3491
- * return chat.turn();
3492
- * }
3493
- * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3494
- * },
3495
- * ```
3496
- */
3497
- function chatTurn() {
3498
- return { [actionTurnBrand]: true };
3499
- }
3500
- function isActionTurn(value) {
3501
- return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
3502
- }
3503
- /**
3504
- * Replace, in a model lane, the run of messages one UI message contributed.
3505
- *
3506
- * Used when a UI message is replaced in place (a tool-approval continuation
3507
- * merging onto the trailing assistant, a captured response reusing an existing
3508
- * id, a partial replacing an existing message). Reconverting the whole lane
3509
- * from the UI lane would also replace a compaction summary with the full
3510
- * transcript and drop the model forms `pendingMessages.prepare` produced.
3511
- *
3512
- * The replaced message is the trailing one, so its run is the lane's tail,
3513
- * before any steer forms appended after it this turn (`tailAfter`). If the
3514
- * tail does not match the old message's conversion, nothing is changed and
3515
- * `false` is returned so the caller can fall back to a full reconversion.
3516
- */
3517
- async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
3518
- const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
3519
- const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
3520
- const end = lane.length - tailAfter;
3521
- const start = end - oldRun.length;
3522
- if (start < 0 || end > lane.length)
3523
- return false;
3524
- if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
3525
- return false;
3526
- lane.splice(start, oldRun.length, ...newRun);
3527
- return true;
3528
- }
3529
3303
  function isUIMessageStreamable(value) {
3530
3304
  return (typeof value === "object" &&
3531
3305
  value !== null &&
3532
3306
  "toUIMessageStream" in value &&
3533
3307
  typeof value.toUIMessageStream === "function");
3534
3308
  }
3535
- const warnedHydrateMessagesDeprecated = new Set();
3536
- function warnHydrateMessagesDeprecatedOnce(agentId) {
3537
- if (warnedHydrateMessagesDeprecated.has(agentId))
3538
- return;
3539
- warnedHydrateMessagesDeprecated.add(agentId);
3540
- console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
3541
- "storage instead: `save` receives every change to the conversation and `loadContext` " +
3542
- "lets the application own the model's context, with crash recovery and durable " +
3543
- "compaction that `hydrateMessages` never had.");
3544
- }
3545
3309
  let warnedMissingOnAction = false;
3546
3310
  function warnMissingOnActionOnce() {
3547
3311
  if (warnedMissingOnAction)
@@ -3674,22 +3438,10 @@ function chatCustomAgent(options) {
3674
3438
  await installChatInputRouter(payload.chatId, {
3675
3439
  resuming: Boolean(payload.continuation),
3676
3440
  });
3677
- // A custom agent's loop is the customer's, so there is no exit site the
3678
- // SDK controls. Perform a requested close when `run()` returns, whatever
3679
- // shape the loop had. Idempotent, so the createSession iterator having
3680
- // already closed on its own exit costs nothing.
3681
- const withClose = async (result) => {
3682
- try {
3683
- return await result;
3684
- }
3685
- finally {
3686
- await performChatClose();
3687
- }
3688
- };
3689
3441
  // Keep the schema-free path identical to the original custom-agent
3690
3442
  // wrapper, including when userRun starts executing.
3691
3443
  if (!parseClientData) {
3692
- return withClose(userRun(payload, runOptions));
3444
+ return userRun(payload, runOptions);
3693
3445
  }
3694
3446
  const isHandoverBoot = payload.trigger === "handover-prepare";
3695
3447
  const isMessagelessBoot = payload.trigger === "preload" ||
@@ -3705,7 +3457,7 @@ function chatCustomAgent(options) {
3705
3457
  writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3706
3458
  });
3707
3459
  if (validated.ok) {
3708
- return withClose(userRun(validated.payload, runOptions));
3460
+ return userRun(validated.payload, runOptions);
3709
3461
  }
3710
3462
  if (isHandoverBoot) {
3711
3463
  const signal = await waitForHandover({
@@ -3741,7 +3493,7 @@ function chatCustomAgent(options) {
3741
3493
  sessionId: next.output.sessionId ?? payload.sessionId,
3742
3494
  idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3743
3495
  };
3744
- return withClose(userRun(recoveredPayload, runOptions));
3496
+ return userRun(recoveredPayload, runOptions);
3745
3497
  },
3746
3498
  });
3747
3499
  // Register clientDataSchema so the CLI converts it to JSONSchema
@@ -3754,19 +3506,7 @@ function chatCustomAgent(options) {
3754
3506
  return task;
3755
3507
  }
3756
3508
  function chatAgent(options) {
3757
- const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
3758
- if (hydrateMessages) {
3759
- if (storage) {
3760
- throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3761
- "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3762
- "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3763
- }
3764
- if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
3765
- throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3766
- "`loadContext`. Both would own the model's context; keep one.");
3767
- }
3768
- warnHydrateMessagesDeprecatedOnce(options.id);
3769
- }
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;
3770
3510
  const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3771
3511
  const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
3772
3512
  // chat.agent does not expose generic retry options (see docstring on
@@ -3849,42 +3589,6 @@ function chatAgent(options) {
3849
3589
  // durable snapshot + `session.out` replay (or `hydrateMessages` if
3850
3590
  // registered) — the wire is delta-only now, no longer a seed.
3851
3591
  let accumulatedMessages = [];
3852
- /**
3853
- * Give the model accumulator the background injections a step-boundary
3854
- * drain handed to the model this turn, and record them for persistence.
3855
- * Returns how many model messages were appended.
3856
- */
3857
- const reconcilePendingBackground = () => {
3858
- const pending = locals.get(chatPendingBackgroundKey);
3859
- if (!pending || pending.length === 0)
3860
- return 0;
3861
- locals.set(chatPendingBackgroundKey, []);
3862
- let appended = 0;
3863
- for (const entry of pending) {
3864
- accumulatedMessages.push(...entry.messages);
3865
- laneInjections.push(entry);
3866
- appended += entry.messages.length;
3867
- }
3868
- return appended;
3869
- };
3870
- /**
3871
- * Give the model accumulator the steering messages a drain consumed,
3872
- * in the form the model actually received. Appended, never reconverted
3873
- * from the UI lane, so a model-only compaction summary survives. Called
3874
- * on both the success and the error path, before the response or the
3875
- * partial joins the lane, so the order stays steer-then-answer.
3876
- */
3877
- const reconcilePendingSteer = (options) => {
3878
- const pending = locals.get(chatPendingSteerKey);
3879
- if (!pending || pending.length === 0)
3880
- return [];
3881
- locals.set(chatPendingSteerKey, []);
3882
- for (const entry of pending) {
3883
- accumulatedMessages.push(...entry.model);
3884
- options?.turnNew?.push(...entry.model);
3885
- }
3886
- return pending;
3887
- };
3888
3592
  // Accumulated UI messages for persistence. Mirrors the model accumulator
3889
3593
  // but in frontend-friendly UIMessage format (with parts, id, etc.).
3890
3594
  let accumulatedUIMessages = [];
@@ -3902,157 +3606,7 @@ function chatAgent(options) {
3902
3606
  // collectively cost ~600ms on every first-message TTFC. Both reads
3903
3607
  // swallow errors internally; the agent stays available either way.
3904
3608
  const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
3905
- const transcriptStorage = storage ??
3906
- transcriptStorageOverride ??
3907
- defaultStorage;
3908
- const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
3909
- /**
3910
- * Who supplies the model's context each turn: the deprecated
3911
- * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
3912
- * the runtime's own transcript.
3913
- */
3914
- const loadContextHook = hydrateMessages
3915
- ? (event) => hydrateMessages(event)
3916
- : storageLoadContext
3917
- ? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
3918
- : undefined;
3919
- let transcriptShadow = createTranscriptShadow([]);
3920
- let bootTranscriptState = null;
3921
- /**
3922
- * True while the model lane holds a compaction summary, so it cannot be
3923
- * rebuilt from the transcript and has to be persisted as state. Reset
3924
- * wherever the lane is reconverted from the UI lane.
3925
- */
3926
- let laneCompacted = false;
3927
- /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
3928
- let laneInjections = [];
3929
- let persistedStateSet = false;
3930
3609
  let bootSnapshot;
3931
- let bootClientData = payload.metadata;
3932
- if (parseClientData) {
3933
- try {
3934
- bootClientData = await parseClientData(payload.metadata);
3935
- }
3936
- catch {
3937
- bootClientData = payload.metadata;
3938
- }
3939
- }
3940
- /**
3941
- * The `lastOutEventId` the most recent snapshot carried.
3942
- *
3943
- * A snapshot written outside a turn — after an action mutates history — has
3944
- * no turn cursor of its own, and writing `undefined` there would drop the
3945
- * resume point and make the next boot replay from further back. Retaining it
3946
- * keeps an action's write cursor-neutral.
3947
- */
3948
- let lastSnapshotOutEventId;
3949
- const storageTrigger = (trigger) => trigger === "regenerate-message"
3950
- ? "regenerate-message"
3951
- : trigger === "action" || trigger === "action-turn"
3952
- ? "action"
3953
- : "submit-message";
3954
- /**
3955
- * Hand the runtime's view of the transcript to the storage as a
3956
- * changeset: the diff against what was last saved, plus the cursors the
3957
- * next boot resumes from. The shadow only advances when the save
3958
- * succeeds, so a failed save is folded into the next changeset.
3959
- */
3960
- /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
3961
- let transcriptState = null;
3962
- const saveTranscript = async (opts) => {
3963
- const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
3964
- nonFinalIds: opts.nonFinalIds,
3965
- });
3966
- const throughId = opts.messages.at(-1)?.id ?? "";
3967
- const queued = locals.get(chatBackgroundQueueKey) ?? [];
3968
- const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
3969
- ? {
3970
- v: 1,
3971
- ...(laneCompacted
3972
- ? {
3973
- compaction: {
3974
- modelMessages: accumulatedMessages,
3975
- throughId,
3976
- fingerprint: prefixFingerprint(shadow, throughId),
3977
- },
3978
- }
3979
- : {}),
3980
- ...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
3981
- ...(queued.length > 0 ? { queued: [...queued] } : {}),
3982
- }
3983
- : null;
3984
- if (runtimeState !== null || persistedStateSet) {
3985
- changes.push({ op: "state", value: runtimeState });
3986
- }
3987
- transcriptState = runtimeState;
3988
- const inCursor = chatInputRouter().resumeFloor();
3989
- await transcriptStorage.save({
3990
- chatId: payload.chatId,
3991
- clientData: opts.clientData,
3992
- turn: opts.turn,
3993
- trigger: opts.trigger,
3994
- runId: ctx.run.id,
3995
- ctx,
3996
- }, {
3997
- reason: opts.reason,
3998
- changes,
3999
- transcript: {
4000
- entries: opts.messages.map((message) => ({
4001
- id: message.id,
4002
- final: !shadow.nonFinal.has(message.id),
4003
- message,
4004
- })),
4005
- state: transcriptState,
4006
- },
4007
- cursors: {
4008
- lastOutEventId: opts.lastOutEventId,
4009
- lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
4010
- },
4011
- });
4012
- transcriptShadow = shadow;
4013
- persistedStateSet = runtimeState !== null;
4014
- };
4015
- /**
4016
- * Persist the accumulator outside a turn.
4017
- *
4018
- * An action is not a turn, so it never reaches the turn-complete path where
4019
- * the transcript is normally saved, but a `chat.history` mutation changes
4020
- * the conversation and has to survive the run ending.
4021
- *
4022
- * Cursor-neutral: an action has no turn cursor of its own, and writing
4023
- * `undefined` would drop the resume point the last turn established and make
4024
- * the next boot replay from further back.
4025
- */
4026
- const writeSnapshotOutsideTurn = async (reason, turnContext) => {
4027
- if (hydrateMessages)
4028
- return;
4029
- try {
4030
- await tracer.startActiveSpan("snapshot.write", async () => {
4031
- await saveTranscript({
4032
- reason: "action",
4033
- messages: accumulatedUIMessages,
4034
- turn: turnContext.turn,
4035
- trigger: "action",
4036
- clientData: turnContext.clientData,
4037
- lastOutEventId: lastSnapshotOutEventId,
4038
- });
4039
- }, {
4040
- attributes: {
4041
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
4042
- [SemanticInternalAttributes.COLLAPSED]: true,
4043
- "chat.snapshot.reason": reason,
4044
- "chat.messages.count": accumulatedUIMessages.length,
4045
- },
4046
- });
4047
- }
4048
- catch (error) {
4049
- logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
4050
- error: error instanceof Error ? error.message : String(error),
4051
- sessionId: sessionIdForSnapshot,
4052
- reason,
4053
- });
4054
- }
4055
- };
4056
3610
  let replayedSettled = [];
4057
3611
  let replayedPartial;
4058
3612
  let replayedPartialRaw;
@@ -4068,7 +3622,7 @@ function chatAgent(options) {
4068
3622
  // resume-cursor block reuses it instead of re-scanning.
4069
3623
  let bootInCursor;
4070
3624
  let bootInCursorResolved = false;
4071
- if (couldHavePriorState) {
3625
+ if (!hydrateMessages && couldHavePriorState) {
4072
3626
  // Single parent span for the whole boot read phase — snapshot
4073
3627
  // read, session.out replay, session.in replay. Per-phase timing
4074
3628
  // + result counts are attributes on the span.
@@ -4076,33 +3630,18 @@ function chatAgent(options) {
4076
3630
  // snapshot read
4077
3631
  const snapStart = Date.now();
4078
3632
  try {
4079
- const loaded = hydrateMessages
4080
- ? undefined
4081
- : await transcriptStorage.load({
4082
- chatId: payload.chatId,
4083
- clientData: bootClientData,
4084
- });
4085
- if (loaded) {
4086
- transcriptShadow = createTranscriptShadow(loaded.messages, new Set(loaded.nonFinalIds ?? []));
4087
- bootTranscriptState = loaded.state;
4088
- transcriptState = loaded.state ?? null;
4089
- persistedStateSet = loaded.state !== null && loaded.state !== undefined;
4090
- bootSnapshot = {
4091
- messages: loaded.messages,
4092
- lastOutEventId: loaded.cursors?.lastOutEventId,
4093
- lastInEventId: loaded.cursors?.lastInEventId,
4094
- };
4095
- }
3633
+ bootSnapshot = await readChatSnapshot(sessionIdForSnapshot);
4096
3634
  }
4097
3635
  catch (error) {
4098
- logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
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", {
4099
3639
  error: error instanceof Error ? error.message : String(error),
4100
3640
  sessionId: sessionIdForSnapshot,
4101
3641
  });
4102
3642
  }
4103
3643
  bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
4104
- bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
4105
- (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
3644
+ bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot);
4106
3645
  bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
4107
3646
  // Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
4108
3647
  // id of the previous turn's `turn-complete` control record). The
@@ -4110,7 +3649,6 @@ function chatAgent(options) {
4110
3649
  // Without seeding, the new worker would emit no trim on its first
4111
3650
  // turn (chain self-bootstraps from turn 2), so this is purely an
4112
3651
  // optimization to keep continuation runs bounded from the first turn.
4113
- lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4114
3652
  if (bootSnapshot?.lastOutEventId !== undefined) {
4115
3653
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
4116
3654
  if (Number.isFinite(seeded)) {
@@ -4205,17 +3743,13 @@ function chatAgent(options) {
4205
3743
  // Reads the turn boundary and subscribes in one call. `bootInCursor` is
4206
3744
  // only a fallback: the boot block above may already have resolved a
4207
3745
  // cursor from the snapshot, which is used when the boundary itself
4208
- // carries none. Everything the boot replayed off `.in` is dispatched from
4209
- // `bootInjectedQueue` below, so it goes into the floor here — folded in
4210
- // after the subscription opens, the live tail re-delivers it as a turn.
4211
- const lastRecoveredInSeq = replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1].seqNum : undefined;
3746
+ // carries none.
4212
3747
  await installChatInputRouter(payload.chatId, {
4213
3748
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4214
- recoveredThrough: lastRecoveredInSeq,
4215
3749
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
4216
3750
  });
4217
3751
  // ── Recovery boot + chain reconstruction ────────────────────────
4218
- {
3752
+ if (!hydrateMessages) {
4219
3753
  const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
4220
3754
  const inFlightUsers = replayedInTail.map((r) => r.message);
4221
3755
  const partialAssistant = replayedPartial;
@@ -4319,6 +3853,16 @@ function chatAgent(options) {
4319
3853
  if (hookBeforeBoot) {
4320
3854
  await hookBeforeBoot();
4321
3855
  }
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
+ }
4322
3866
  // Synthesize wire payloads for each recoveredTurn. The turn-loop
4323
3867
  // pops these ahead of `messagesInput.waitWithIdleTimeout` so they
4324
3868
  // dispatch as normal turns with the existing hook stack.
@@ -4368,8 +3912,7 @@ function chatAgent(options) {
4368
3912
  // path where wire-borne UIMessage[] still seeds the accumulator,
4369
3913
  // and it's safe because the route handler isn't subject to the
4370
3914
  // `/in/append` 512 KiB cap.
4371
- if (!loadContextHook &&
4372
- accumulatedUIMessages.length === 0 &&
3915
+ if (accumulatedUIMessages.length === 0 &&
4373
3916
  payload.trigger === "handover-prepare" &&
4374
3917
  Array.isArray(payload.headStartMessages) &&
4375
3918
  payload.headStartMessages.length > 0) {
@@ -4398,17 +3941,7 @@ function chatAgent(options) {
4398
3941
  }
4399
3942
  }
4400
3943
  try {
4401
- const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
4402
- const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4403
- accumulatedMessages = restored.messages;
4404
- laneCompacted = restored.compacted;
4405
- laneInjections = restored.injections;
4406
- if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4407
- locals.set(chatBackgroundQueueKey, [
4408
- ...(locals.get(chatBackgroundQueueKey) ?? []),
4409
- ...bootRuntimeState.queued,
4410
- ]);
4411
- }
3944
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
4412
3945
  }
4413
3946
  catch (error) {
4414
3947
  logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
@@ -4422,12 +3955,6 @@ function chatAgent(options) {
4422
3955
  // before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
4423
3956
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4424
3957
  }
4425
- locals.set(chatAgentManagedConfigKey, {
4426
- registry: promptRegistry,
4427
- system: agentSystem,
4428
- cacheControl: agentCacheControl,
4429
- systemProviderOptions: agentSystemProviderOptions,
4430
- });
4431
3958
  // Token usage tracking across turns
4432
3959
  let previousTurnUsage;
4433
3960
  let cumulativeUsage = emptyUsage();
@@ -4785,7 +4312,6 @@ function chatAgent(options) {
4785
4312
  } // end else (no boot-injected first turn)
4786
4313
  }
4787
4314
  for (let turn = 0; turn < maxTurns; turn++) {
4788
- let turnClientData = payload.metadata;
4789
4315
  // Declared here so the finally can detach it — a handler leaked past
4790
4316
  // its turn duplicates every mid-stream message into the shared buffer.
4791
4317
  let turnMsgSub;
@@ -4807,7 +4333,6 @@ function chatAgent(options) {
4807
4333
  // `cleanupAbortedParts` no longer has to be re-applied below.
4808
4334
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4809
4335
  const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4810
- turnClientData = clientData;
4811
4336
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
4812
4337
  // Actions are not turns. They use a different span name
4813
4338
  // and don't carry a turn.number. Branched on at `isAction`.
@@ -4844,7 +4369,6 @@ function chatAgent(options) {
4844
4369
  locals.set(chatDeferKey, new Set());
4845
4370
  locals.set(chatCompactionStateKey, undefined);
4846
4371
  locals.set(chatSteeringQueueKey, []);
4847
- locals.set(chatPendingBackgroundKey, []);
4848
4372
  locals.set(chatResponsePartsKey, []);
4849
4373
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
4850
4374
  // by deferred work from the previous turn's onTurnComplete need to
@@ -4926,10 +4450,6 @@ function chatAgent(options) {
4926
4450
  // Track new messages for this turn (user input + assistant response).
4927
4451
  const turnNewModelMessages = [];
4928
4452
  const turnNewUIMessages = [];
4929
- locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
4930
- // A head-start handover deliberately resumes from an assistant
4931
- // message it spliced in, so it isn't a no-op turn.
4932
- let splicedHandoverPartial = false;
4933
4453
  // ── Action handling ──────────────────────────────────────
4934
4454
  // Actions arrive on the same input stream but with
4935
4455
  // trigger === "action". They are NOT turns — only
@@ -4940,25 +4460,16 @@ function chatAgent(options) {
4940
4460
  // an action, return a `StreamTextResult` (auto-piped),
4941
4461
  // string, or UIMessage from `onAction`. Turn counter
4942
4462
  // does not advance.
4943
- let actionResult = undefined;
4944
- /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
4945
- let actionTurn = false;
4946
- /**
4947
- * Whether this action changed the conversation, by rolling history
4948
- * back or by streaming a response. Drives the single snapshot write
4949
- * at the end — an action never reaches the turn-complete path that
4950
- * normally does it.
4951
- */
4952
- let actionChangedHistory = false;
4463
+ let actionStreamResult = undefined;
4953
4464
  if (isAction) {
4954
4465
  // Parse and validate the action payload
4955
4466
  const parsedAction = parseAction
4956
4467
  ? await parseAction(currentWirePayload.action)
4957
4468
  : currentWirePayload.action;
4958
4469
  // Hydrate messages from backend if configured
4959
- if (loadContextHook) {
4470
+ if (hydrateMessages) {
4960
4471
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
4961
- return loadContextHook({
4472
+ return hydrateMessages({
4962
4473
  chatId: currentWirePayload.chatId,
4963
4474
  turn,
4964
4475
  trigger: "action",
@@ -4978,14 +4489,12 @@ function chatAgent(options) {
4978
4489
  });
4979
4490
  accumulatedUIMessages = [...hydrated];
4980
4491
  accumulatedMessages = await toModelMessages(hydrated);
4981
- laneCompacted = false;
4982
- laneInjections = [];
4983
4492
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4984
4493
  }
4985
4494
  // Fire onAction — handler may mutate state via
4986
4495
  // `chat.history.*` and / or return a model response.
4987
4496
  if (onAction) {
4988
- actionResult = await tracer.startActiveSpan("onAction()", async () => {
4497
+ actionStreamResult = await tracer.startActiveSpan("onAction()", async () => {
4989
4498
  return await onAction({
4990
4499
  action: parsedAction,
4991
4500
  chatId: currentWirePayload.chatId,
@@ -5010,10 +4519,7 @@ function chatAgent(options) {
5010
4519
  locals.set(chatOverrideMessagesKey, undefined);
5011
4520
  accumulatedUIMessages = [...actionOverride];
5012
4521
  accumulatedMessages = await toModelMessages(actionOverride);
5013
- laneCompacted = false;
5014
- laneInjections = [];
5015
4522
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5016
- actionChangedHistory = true;
5017
4523
  }
5018
4524
  }
5019
4525
  else {
@@ -5034,7 +4540,7 @@ function chatAgent(options) {
5034
4540
  // route handler's first-turn history to the hydrate hook as
5035
4541
  // incoming messages instead (gated on the pending handover).
5036
4542
  if (turn === 0 &&
5037
- loadContextHook &&
4543
+ hydrateMessages &&
5038
4544
  cleanedUIMessages.length === 0 &&
5039
4545
  (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
5040
4546
  Array.isArray(payload.headStartMessages) &&
@@ -5064,7 +4570,7 @@ function chatAgent(options) {
5064
4570
  },
5065
4571
  }));
5066
4572
  }
5067
- if (loadContextHook) {
4573
+ if (hydrateMessages) {
5068
4574
  // Snapshot the ids the accumulator knew BEFORE this
5069
4575
  // turn ran — used below to decide whether an
5070
4576
  // incoming wire message is genuinely new or just a
@@ -5082,7 +4588,7 @@ function chatAgent(options) {
5082
4588
  // was always true for `submit-message` is now true for every
5083
4589
  // trigger.
5084
4590
  const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
5085
- return loadContextHook({
4591
+ return hydrateMessages({
5086
4592
  chatId: currentWirePayload.chatId,
5087
4593
  turn,
5088
4594
  trigger: currentWirePayload.trigger,
@@ -5121,8 +4627,6 @@ function chatAgent(options) {
5121
4627
  }
5122
4628
  accumulatedUIMessages = merged;
5123
4629
  accumulatedMessages = await toModelMessages(merged);
5124
- laneCompacted = false;
5125
- laneInjections = [];
5126
4630
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5127
4631
  // Track new messages for onTurnComplete.newUIMessages.
5128
4632
  // Only push for genuinely new ids — HITL continuations
@@ -5168,8 +4672,6 @@ function chatAgent(options) {
5168
4672
  accumulatedUIMessages.pop();
5169
4673
  }
5170
4674
  accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5171
- laneCompacted = false;
5172
- laneInjections = [];
5173
4675
  }
5174
4676
  else if (cleanedUIMessages.length > 0) {
5175
4677
  // Submit-message (and the special-cased
@@ -5186,7 +4688,6 @@ function chatAgent(options) {
5186
4688
  // where AI SDK regenerates the id (TRI-9137) still
5187
4689
  // applies via `rewriteIncomingIdViaToolCallMap`.
5188
4690
  let replaced = false;
5189
- const replacedPairs = [];
5190
4691
  for (const raw of cleanedUIMessages) {
5191
4692
  let incoming = raw;
5192
4693
  let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
@@ -5198,9 +4699,7 @@ function chatAgent(options) {
5198
4699
  }
5199
4700
  }
5200
4701
  if (idx !== -1) {
5201
- const previous = accumulatedUIMessages[idx];
5202
- accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
5203
- replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
4702
+ accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(accumulatedUIMessages[idx], incoming);
5204
4703
  replaced = true;
5205
4704
  }
5206
4705
  else {
@@ -5210,19 +4709,9 @@ function chatAgent(options) {
5210
4709
  recordToolCallIdsFromMessage(incoming);
5211
4710
  }
5212
4711
  if (replaced) {
5213
- let inPlace = true;
5214
- for (const { previous, merged } of replacedPairs) {
5215
- if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
5216
- inPlace = false;
5217
- break;
5218
- }
5219
- }
5220
- if (!inPlace) {
5221
- logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
5222
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5223
- laneCompacted = false;
5224
- laneInjections = [];
5225
- }
4712
+ // Replacement changes structure — reconvert all model
4713
+ // messages instead of appending.
4714
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5226
4715
  }
5227
4716
  else {
5228
4717
  const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -5265,33 +4754,10 @@ function chatAgent(options) {
5265
4754
  messageId: locals.get(chatHandoverMessageIdKey),
5266
4755
  });
5267
4756
  locals.set(chatHandoverPartialKey, []); // consume once
5268
- splicedHandoverPartial = true;
5269
4757
  }
5270
4758
  }
5271
4759
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5272
4760
  } // end if (trigger !== "action")
5273
- // ── No-op turn ──────────────────────────────────────────
5274
- //
5275
- // A submit that added no new user message and leaves the model
5276
- // chain ending on an assistant message has nothing to answer —
5277
- // calling the model would prefill its own last reply. Keyed on
5278
- // the model tail, so a `tool`-terminated chain (a merged tool
5279
- // approval) still runs.
5280
- const isNoOpTurn = !isAction &&
5281
- !splicedHandoverPartial &&
5282
- currentWirePayload.trigger === "submit-message" &&
5283
- turnNewUIMessages.length === 0 &&
5284
- accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
5285
- if (isNoOpTurn) {
5286
- msgSub?.off();
5287
- logger.warn("chat.agent: turn added no new user message; skipping the model", {
5288
- chatId: currentWirePayload.chatId,
5289
- messageId: currentWirePayload.messageId,
5290
- });
5291
- await writeTurnCompleteChunk(currentWirePayload.chatId);
5292
- // Not a turn — don't consume an iteration.
5293
- turn--;
5294
- }
5295
4761
  // ── Action result handling ──────────────────────────────
5296
4762
  // For action turns, skip the turn machinery entirely.
5297
4763
  // If `onAction` returned a stream / string / UIMessage,
@@ -5301,34 +4767,34 @@ function chatAgent(options) {
5301
4767
  // The turn counter is decremented so the next iteration
5302
4768
  // sees the same `turn` value — actions don't count.
5303
4769
  if (isAction) {
5304
- if (isActionTurn(actionResult)) {
5305
- // Persist the edit before the turn starts, so a turn that is
5306
- // cancelled or runs out of memory continues from the edited
5307
- // history rather than from the snapshot the edit replaced.
5308
- // The turn then does its own hooks, completion and snapshot.
5309
- if (actionChangedHistory) {
5310
- await writeSnapshotOutsideTurn("action", { turn, clientData });
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
+ });
5311
4783
  }
5312
- actionTurn = true;
5313
- }
5314
- else if (actionResult !== undefined) {
5315
- throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
5316
- "after the edit, return chat.turn() and a turn runs on the edited history. " +
5317
- "Returning a StreamTextResult, string or UIMessage is no longer supported.");
5318
- }
5319
- else {
5320
- msgSub?.off();
5321
- if (actionChangedHistory) {
5322
- await writeSnapshotOutsideTurn("action", { turn, clientData });
4784
+ catch (error) {
4785
+ if (error instanceof Error &&
4786
+ error.name === "AbortError" &&
4787
+ runSignal.aborted) {
4788
+ return "exit";
4789
+ }
4790
+ throw error;
5323
4791
  }
5324
- await writeTurnCompleteChunk(currentWirePayload.chatId);
5325
- // Don't consume a turn iteration — actions aren't turns.
5326
- turn--;
5327
4792
  }
4793
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
4794
+ // Don't consume a turn iteration — actions aren't turns.
4795
+ turn--;
5328
4796
  }
5329
- // A no-op turn skips this block, and with it `followSessionPin`:
5330
- // there is nothing to answer, so nothing to hand over.
5331
- if ((!isAction || actionTurn) && !isNoOpTurn) {
4797
+ if (!isAction) {
5332
4798
  // Mint a scoped public access token once per turn, reused for
5333
4799
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
5334
4800
  const currentRunId = ctx.run.id;
@@ -5417,8 +4883,6 @@ function chatAgent(options) {
5417
4883
  locals.set(chatOverrideMessagesKey, undefined);
5418
4884
  accumulatedUIMessages = [...turnStartOverride];
5419
4885
  accumulatedMessages = await toModelMessages(turnStartOverride);
5420
- laneCompacted = false;
5421
- laneInjections = [];
5422
4886
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5423
4887
  }
5424
4888
  }, {
@@ -5435,11 +4899,9 @@ function chatAgent(options) {
5435
4899
  },
5436
4900
  });
5437
4901
  }
5438
- await followSessionPin(currentWirePayload.chatId, versionSkew);
5439
4902
  // chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
5440
- // skip run() and hand over to a fresh run on the new version. The
5441
- // successor picks the message up off session.in; the transport only
5442
- // keeps reading.
4903
+ // skip run() and signal the transport to re-trigger the same message
4904
+ // on the new version.
5443
4905
  if (locals.get(chatUpgradeRequestedKey)) {
5444
4906
  await writeUpgradeRequiredChunk();
5445
4907
  return "exit";
@@ -5479,12 +4941,7 @@ function chatAgent(options) {
5479
4941
  const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
5480
4942
  const bgQueue = locals.get(chatBackgroundQueueKey);
5481
4943
  if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
5482
- const injected = bgQueue.splice(0);
5483
- accumulatedMessages.push(...injected);
5484
- laneInjections.push({
5485
- afterId: accumulatedUIMessages.at(-1)?.id ?? "",
5486
- messages: injected,
5487
- });
4944
+ accumulatedMessages.push(...bgQueue.splice(0));
5488
4945
  }
5489
4946
  if (isHeadStartFinalTurn) {
5490
4947
  // The synthesized partial UIMessage IS the response.
@@ -5503,9 +4960,6 @@ function chatAgent(options) {
5503
4960
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
5504
4961
  runResult = await userRun({
5505
4962
  ...restWire,
5506
- // A turn requested by chat.turn() is not the action itself:
5507
- // a run() that short-circuits on "action" must still answer.
5508
- ...(actionTurn ? { trigger: "action-turn" } : {}),
5509
4963
  messages: preparedMessages,
5510
4964
  clientData,
5511
4965
  continuation,
@@ -5518,7 +4972,6 @@ function chatAgent(options) {
5518
4972
  signal: combinedSignal,
5519
4973
  cancelSignal,
5520
4974
  stopSignal,
5521
- streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
5522
4975
  });
5523
4976
  }
5524
4977
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5632,21 +5085,7 @@ function chatAgent(options) {
5632
5085
  if (runOverride) {
5633
5086
  locals.set(chatOverrideMessagesKey, undefined);
5634
5087
  accumulatedUIMessages = [...runOverride];
5635
- /**
5636
- * Steers the drain consumed are left out of the rebuild and
5637
- * appended by the reconciliation below instead, so the lane
5638
- * gets the form the model actually received rather than a
5639
- * reconversion of the UI message, and gets it once. A steer
5640
- * the edit removed is dropped from the pending list too, so
5641
- * the edit is honoured.
5642
- */
5643
- const overrideIds = new Set(runOverride.map((m) => m.id));
5644
- const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
5645
- locals.set(chatPendingSteerKey, pending);
5646
- const pendingIds = new Set(pending.map((e) => e.ui.id));
5647
- accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
5648
- laneCompacted = false;
5649
- laneInjections = [];
5088
+ accumulatedMessages = await toModelMessages(runOverride);
5650
5089
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5651
5090
  }
5652
5091
  // Check if compaction set a model-only override (preserves UI messages).
@@ -5669,8 +5108,6 @@ function chatAgent(options) {
5669
5108
  accumulatedMessages = taskCompactionConfig?.compactModelMessages
5670
5109
  ? await taskCompactionConfig.compactModelMessages(compactEvent)
5671
5110
  : modelOnlyOverride;
5672
- laneCompacted = true;
5673
- laneInjections = [];
5674
5111
  // Apply UI messages: callback or default (preserve all)
5675
5112
  if (taskCompactionConfig?.compactUIMessages) {
5676
5113
  accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
@@ -5678,15 +5115,6 @@ function chatAgent(options) {
5678
5115
  }
5679
5116
  // Determine if the user stopped generation this turn (not a full run cancel).
5680
5117
  const wasStopped = stopController.signal.aborted && !runSignal.aborted;
5681
- // Give the model accumulator the steering messages the drain
5682
- // consumed. Appended, never reconverted from the UI lane, so a
5683
- // model-only compaction summary set just above survives; and done
5684
- // before the response is appended so the order stays
5685
- // steer-then-answer. Outside the `capturedResponseMessage`
5686
- // branches below, so a turn that captured no response is covered.
5687
- const steerTailThisTurn = reconcilePendingSteer({
5688
- turnNew: turnNewModelMessages,
5689
- }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
5690
5118
  // Append the assistant's response (partial or complete) to the accumulator.
5691
5119
  // The onFinish callback fires even on abort/stop, so partial responses
5692
5120
  // from stopped generation are captured correctly.
@@ -5724,7 +5152,6 @@ function chatAgent(options) {
5724
5152
  const existingIdx = capturedResponseMessage.id
5725
5153
  ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
5726
5154
  : -1;
5727
- const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
5728
5155
  if (existingIdx !== -1) {
5729
5156
  accumulatedUIMessages[existingIdx] = capturedResponseMessage;
5730
5157
  }
@@ -5744,14 +5171,8 @@ function chatAgent(options) {
5744
5171
  stripProviderMetadata(capturedResponseMessage),
5745
5172
  ]);
5746
5173
  if (existingIdx !== -1) {
5747
- const ok = previousAtIdx !== undefined &&
5748
- (await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
5749
- if (!ok) {
5750
- logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
5751
- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5752
- laneCompacted = false;
5753
- laneInjections = [];
5754
- }
5174
+ // Reconvert all model messages since we replaced rather than appended
5175
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
5755
5176
  }
5756
5177
  else {
5757
5178
  accumulatedMessages.push(...responseModelMessages);
@@ -5859,8 +5280,6 @@ function chatAgent(options) {
5859
5280
  ],
5860
5281
  },
5861
5282
  ];
5862
- laneCompacted = true;
5863
- laneInjections = [];
5864
5283
  // UI messages: callback or default (preserve all)
5865
5284
  if (outerCompaction.compactUIMessages) {
5866
5285
  accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
@@ -5950,8 +5369,6 @@ function chatAgent(options) {
5950
5369
  locals.set(chatOverrideMessagesKey, undefined);
5951
5370
  accumulatedUIMessages = [...override];
5952
5371
  accumulatedMessages = await toModelMessages(override);
5953
- laneCompacted = false;
5954
- laneInjections = [];
5955
5372
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5956
5373
  // Update event so onTurnComplete sees compacted messages
5957
5374
  turnCompleteEvent.messages = accumulatedMessages;
@@ -5998,8 +5415,6 @@ function chatAgent(options) {
5998
5415
  locals.set(chatOverrideMessagesKey, undefined);
5999
5416
  accumulatedUIMessages = [...turnCompleteOverride];
6000
5417
  accumulatedMessages = await toModelMessages(turnCompleteOverride);
6001
- laneCompacted = false;
6002
- laneInjections = [];
6003
5418
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6004
5419
  }
6005
5420
  }, {
@@ -6053,18 +5468,13 @@ function chatAgent(options) {
6053
5468
  if (!hydrateMessages) {
6054
5469
  try {
6055
5470
  await tracer.startActiveSpan("snapshot.write", async () => {
6056
- lastSnapshotOutEventId =
6057
- turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6058
- await saveTranscript({
6059
- reason: "turn-complete",
5471
+ const snapshotInCursor = chatInputRouter().resumeFloor();
5472
+ await writeChatSnapshot(sessionIdForSnapshot, {
5473
+ version: 1,
5474
+ savedAt: Date.now(),
6060
5475
  messages: accumulatedUIMessages,
6061
- turn,
6062
- trigger: storageTrigger(currentWirePayload.trigger),
6063
- clientData,
6064
- lastOutEventId: lastSnapshotOutEventId,
6065
- nonFinalIds: wasStopped && capturedResponseMessage?.id
6066
- ? new Set([capturedResponseMessage.id])
6067
- : undefined,
5476
+ lastOutEventId: turnCompleteResult?.lastEventId,
5477
+ lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,
6068
5478
  });
6069
5479
  }, {
6070
5480
  attributes: {
@@ -6097,17 +5507,10 @@ function chatAgent(options) {
6097
5507
  currentWirePayload = bootInjectedQueue.shift();
6098
5508
  return "continue";
6099
5509
  }
6100
- // chat.requestUpgrade() was called — exit the loop; the handover
6101
- // has already triggered a new run on the latest version.
5510
+ // chat.requestUpgrade() was called — exit the loop so the
5511
+ // transport triggers a new run on the latest version.
6102
5512
  // chat.endRun() — same exit, no upgrade semantics.
6103
- if (locals.get(chatCloseRequestedKey)) {
6104
- await performChatClose();
6105
- return "exit";
6106
- }
6107
5513
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6108
- if (locals.get(chatUpgradeRequestedKey)) {
6109
- await persistUpgradeHandoff();
6110
- }
6111
5514
  return "exit";
6112
5515
  }
6113
5516
  // Wait for the next message — stay idle briefly, then suspend
@@ -6214,10 +5617,6 @@ function chatAgent(options) {
6214
5617
  });
6215
5618
  // Signal turn complete so the client knows this turn is done
6216
5619
  errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
6217
- // A later action's snapshot reuses this cursor, so it has to move
6218
- // here too or that snapshot resumes from before the failed turn.
6219
- lastSnapshotOutEventId =
6220
- errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
6221
5620
  }
6222
5621
  catch {
6223
5622
  // Best-effort — if stream write fails, let the run continue anyway
@@ -6262,47 +5661,15 @@ function chatAgent(options) {
6262
5661
  : partialIdx === -1
6263
5662
  ? [...erroredUIMessages, partialResponse]
6264
5663
  : erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
6265
- /**
6266
- * Seeded from the per-turn list, not just the wire message and the
6267
- * partial, so a steering message the drain consumed is reported too.
6268
- * An app persisting from `newUIMessages` would otherwise lose the
6269
- * instruction whenever the turn it steered went on to fail.
6270
- */
6271
- const buildErroredNew = () => {
6272
- const out = [];
6273
- const addUnique = (m) => {
6274
- if (m && !out.some((existing) => existing.id === m.id))
6275
- out.push(m);
6276
- };
6277
- addUnique(erroredWireMessage);
6278
- for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? [])) {
6279
- addUnique(m);
6280
- }
6281
- if (includePartial)
6282
- addUnique(partialResponse);
6283
- return out;
6284
- };
6285
- let erroredNewUIMessages = buildErroredNew();
5664
+ let erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
5665
+ if (includePartial) {
5666
+ erroredNewUIMessages.push(partialResponse);
5667
+ }
6286
5668
  let erroredNewModelMessages = [];
6287
- const reconciledSteer = reconcilePendingSteer();
6288
- const backgroundTailThisTurn = reconcilePendingBackground();
6289
5669
  if (!responseCommitted) {
6290
5670
  try {
6291
5671
  if (erroredNewUIMessages.length > 0) {
6292
- /**
6293
- * Built in order from the recorded forms rather than by
6294
- * converting the UI list, so a steer appears in the delta as
6295
- * the model received it (what `prepare` produced), matching the
6296
- * lane. The wire message and partial are converted as before.
6297
- */
6298
- const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
6299
- for (const m of erroredNewUIMessages) {
6300
- const recorded = steerModelById.get(m.id);
6301
- if (recorded)
6302
- erroredNewModelMessages.push(...recorded);
6303
- else
6304
- erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
6305
- }
5672
+ erroredNewModelMessages = await toModelMessages(erroredNewUIMessages.map((m) => stripProviderMetadata(m)));
6306
5673
  }
6307
5674
  if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
6308
5675
  if (partialIdx === -1) {
@@ -6310,14 +5677,7 @@ function chatAgent(options) {
6310
5677
  accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
6311
5678
  }
6312
5679
  else {
6313
- const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
6314
- backgroundTailThisTurn);
6315
- if (!ok) {
6316
- logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
6317
- accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6318
- laneCompacted = false;
6319
- laneInjections = [];
6320
- }
5680
+ accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
6321
5681
  }
6322
5682
  accumulatedUIMessages = erroredUIMessagesWithPartial;
6323
5683
  locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
@@ -6326,7 +5686,7 @@ function chatAgent(options) {
6326
5686
  catch {
6327
5687
  erroredNewModelMessages = [];
6328
5688
  erroredUIMessagesWithPartial = erroredUIMessages;
6329
- erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
5689
+ erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
6330
5690
  }
6331
5691
  }
6332
5692
  if (onTurnComplete) {
@@ -6380,14 +5740,13 @@ function chatAgent(options) {
6380
5740
  // neither the snapshot nor the replayable `.in` tail.
6381
5741
  if (!hydrateMessages) {
6382
5742
  try {
6383
- await saveTranscript({
6384
- reason: "turn-error",
5743
+ const errorSnapshotInCursor = chatInputRouter().resumeFloor();
5744
+ await writeChatSnapshot(sessionIdForSnapshot, {
5745
+ version: 1,
5746
+ savedAt: Date.now(),
6385
5747
  messages: erroredUIMessagesWithPartial,
6386
- turn,
6387
- trigger: storageTrigger(currentWirePayload.trigger),
6388
- clientData: turnClientData,
6389
- lastOutEventId: lastSnapshotOutEventId,
6390
- nonFinalIds: includePartial && partialResponse ? new Set([partialResponse.id]) : undefined,
5748
+ lastOutEventId: errorTurnCompleteResult?.lastEventId,
5749
+ lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
6391
5750
  });
6392
5751
  }
6393
5752
  catch (error) {
@@ -6397,15 +5756,8 @@ function chatAgent(options) {
6397
5756
  });
6398
5757
  }
6399
5758
  }
6400
- if (locals.get(chatCloseRequestedKey)) {
6401
- await performChatClose();
6402
- return;
6403
- }
6404
5759
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
6405
5760
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
6406
- if (locals.get(chatUpgradeRequestedKey)) {
6407
- await persistUpgradeHandoff();
6408
- }
6409
5761
  return;
6410
5762
  }
6411
5763
  // Drain remaining recovered turns before idling — a thrown
@@ -6428,12 +5780,6 @@ function chatAgent(options) {
6428
5780
  return; // Timed out — end run gracefully
6429
5781
  }
6430
5782
  currentWirePayload = next.output;
6431
- // Same close check the success path makes. Without it a close
6432
- // record that lands after a failed turn is consumed as if it were
6433
- // a turn payload, and the loop runs on against a closed session.
6434
- if (currentWirePayload.trigger === "close") {
6435
- return;
6436
- }
6437
5783
  // Continue to next iteration of the for loop
6438
5784
  }
6439
5785
  finally {
@@ -6442,11 +5788,6 @@ function chatAgent(options) {
6442
5788
  }
6443
5789
  }
6444
5790
  finally {
6445
- // Safety net for a close requested on a path that exits without
6446
- // reaching one of the loop's close checks (a turn timeout, an OOM
6447
- // re-throw). `performChatClose` is idempotent, so the ordinary path
6448
- // having already run it costs nothing here.
6449
- await performChatClose();
6450
5791
  // `stopSub` is registered post-preload so the close-during-preload
6451
5792
  // early-return path may exit before it ever attached. Guard the
6452
5793
  // cleanup so a missing subscription doesn't throw.
@@ -6784,22 +6125,15 @@ function isStopped() {
6784
6125
  // Version upgrade
6785
6126
  // ---------------------------------------------------------------------------
6786
6127
  /**
6787
- * Hand the conversation over to another deployment.
6788
- *
6789
- * The handover happens immediately and server-side: a successor run is created
6790
- * and picks the conversation up from `session.in`. The transport keeps reading
6791
- * the same session output, so no client action is needed and nothing waits for
6792
- * the next message.
6793
- *
6794
- * Without a target the session's pin is cleared, so the successor lands on the
6795
- * latest deployed version; with `externalDeploymentId` the session is re-pinned
6796
- * to that deployment.
6128
+ * Request that the current run exits so the next message starts on the latest
6129
+ * deployed version (via the standard continuation mechanism).
6797
6130
  *
6798
6131
  * When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped
6799
- * entirely and the successor answers the message that opened the turn.
6132
+ * entirely — the run exits immediately and the transport re-triggers the
6133
+ * same message on the new version.
6800
6134
  *
6801
6135
  * When called from `run()` or `chat.defer()`, the current turn completes
6802
- * normally and the handover happens afterward.
6136
+ * normally and the run exits afterward instead of waiting for the next message.
6803
6137
  *
6804
6138
  * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
6805
6139
  * or inside `chat.defer()`.
@@ -6819,37 +6153,8 @@ function isStopped() {
6819
6153
  * });
6820
6154
  * ```
6821
6155
  */
6822
- function requestUpgrade(options) {
6156
+ function requestUpgrade() {
6823
6157
  locals.set(chatUpgradeRequestedKey, true);
6824
- // Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
6825
- const target = options?.externalDeploymentId?.trim();
6826
- if (target)
6827
- locals.set(chatUpgradeExternalDeploymentIdKey, target);
6828
- }
6829
- /** @internal Requests a handoff when the session's pin no longer names this deployment. */
6830
- async function followSessionPin(chatId, policy) {
6831
- if (!chatId) {
6832
- return;
6833
- }
6834
- const deployedExternalId = locals.get(chatAgentRunContextKey)?.deployment?.externalId;
6835
- if (policy !== "hold" && !deployedExternalId) {
6836
- logger.debug("chat.versionSkew: cannot follow the session pin", {
6837
- chatId,
6838
- reason: "the run context carries no deployment.externalId",
6839
- });
6840
- }
6841
- const target = await resolvePinToFollow({
6842
- policy,
6843
- deployedExternalId,
6844
- upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
6845
- readPin: async () => (await sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
6846
- .triggerConfig,
6847
- });
6848
- if (!target) {
6849
- return;
6850
- }
6851
- logger.info("chat.versionSkew: following the session pin", { chatId, target });
6852
- requestUpgrade({ externalDeploymentId: target });
6853
6158
  }
6854
6159
  /**
6855
6160
  * Hand off the current custom agent Session to a fresh run.
@@ -6888,31 +6193,20 @@ async function endAndContinue() {
6888
6193
  if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
6889
6194
  throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
6890
6195
  }
6891
- await performEndAndContinue({ reason: "continuation" });
6196
+ await performEndAndContinue();
6892
6197
  }
6893
6198
  /** @internal Shared server handoff used by managed and custom agent loops. */
6894
- async function performEndAndContinue(options) {
6199
+ async function performEndAndContinue() {
6895
6200
  const chatId = locals.get(chatExternalIdKey);
6896
6201
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
6897
6202
  if (!chatId || !callingRunId) {
6898
6203
  throw new Error("Cannot end and continue without an active chat agent run");
6899
6204
  }
6900
- const externalDeploymentId = options.externalDeploymentId;
6901
6205
  const apiClient = apiClientManager.clientOrThrow();
6902
- const result = await apiClient.endAndContinueSession(chatId, {
6206
+ await apiClient.endAndContinueSession(chatId, {
6903
6207
  callingRunId,
6904
- reason: options.reason,
6905
- ...(externalDeploymentId ? { externalDeploymentId } : {}),
6208
+ reason: "upgrade",
6906
6209
  });
6907
- if (result?.pendingVersion !== true) {
6908
- return;
6909
- }
6910
- // The successor parked. Say so on `.out` while this run still can — the transport's
6911
- // subscription survives the swap, so the client learns without waiting for its next send.
6912
- const [error] = await tryCatch(getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
6913
- if (error) {
6914
- logger.warn("could not signal a parked handoff", { chatId, error });
6915
- }
6916
6210
  }
6917
6211
  /**
6918
6212
  * Exit the run after the current turn completes, without waiting for the
@@ -6943,124 +6237,6 @@ async function performEndAndContinue(options) {
6943
6237
  function endRun() {
6944
6238
  locals.set(chatEndRunRequestedKey, true);
6945
6239
  }
6946
- /**
6947
- * End the whole conversation, permanently. The session row is closed, further
6948
- * appends are refused, and the run exits without scheduling a continuation.
6949
- *
6950
- * This is the session-level stop. {@link endRun} ends the current run and lets
6951
- * the next message start a fresh one; `chat.close()` ends the session itself,
6952
- * so there is no next message. Use it for a budget cap, a completed goal,
6953
- * abuse detection, or a user signing out.
6954
- *
6955
- * In a `chat.agent`, call it from `run()`, `prepareStep`, or
6956
- * `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
6957
- * `streamText` the same way the stop signal does, so the partial response is
6958
- * still captured and streamed. The turn then completes normally, a terminal
6959
- * `session-closed` record carrying `reason` is written to the response stream,
6960
- * and the loop exits.
6961
- *
6962
- * Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
6963
- * fields but still runs while the stream is open, so the closed state rides
6964
- * out on the turn's final record. `onTurnComplete` runs after that record, so
6965
- * a close decided there does not reach a reader that has already finished the
6966
- * turn, and the user only finds out when their next message is refused.
6967
- *
6968
- * In a `chat.customAgent`, call it anywhere in your own loop. The close is
6969
- * performed when `run()` returns, so it lands whether you break out of a
6970
- * `chat.createSession` loop, return early, or hand-roll the loop entirely.
6971
- *
6972
- * Closing is one-way: a closed session cannot be reopened. Its transcript
6973
- * stays readable.
6974
- *
6975
- * @example
6976
- * ```ts
6977
- * chat.agent({
6978
- * id: "budgeted-agent",
6979
- * onBeforeTurnComplete: async ({ usage }) => {
6980
- * if (await overBudget(usage)) {
6981
- * chat.close({ reason: "Monthly budget reached" });
6982
- * }
6983
- * },
6984
- * });
6985
- * ```
6986
- */
6987
- function close(options) {
6988
- if (!locals.get(chatExternalIdKey)) {
6989
- throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
6990
- }
6991
- // Bound the reason once, here. It goes out on S2 record headers as well as
6992
- // the close API, and an oversized value would fail the turn-complete write
6993
- // that carries the turn boundary, costing the client far more than the
6994
- // reason text.
6995
- // Trailing high surrogate: the cut landed between the two halves of an
6996
- // astral character, and encoding the orphan to UTF-8 for a record header
6997
- // yields a replacement character. Drop it rather than ship mojibake.
6998
- const reason = options?.reason
6999
- ?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
7000
- .replace(/[\uD800-\uDBFF]$/, "");
7001
- locals.set(chatCloseRequestedKey, reason ? { reason } : {});
7002
- // Mid-step call: unblock the in-flight streamText exactly like the stop
7003
- // signal, so the turn can reach its turn boundary instead of running the
7004
- // model out to completion after the decision to close has been made.
7005
- locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
7006
- }
7007
- /**
7008
- * @internal Terminal close sequence, run once at whichever exit site observes
7009
- * the close request. Writes the standalone `session-closed` record, then closes
7010
- * the session row.
7011
- *
7012
- * The record lands after the turn's `turn-complete`, so a client reading that
7013
- * turn's stream has already terminated on it and will not see this one. It is
7014
- * there for a reconnect and for replay. What a live client reads is the
7015
- * `session-closed` header stamped onto `turn-complete` itself by
7016
- * `writeTurnCompleteChunk`, which fires whenever the close was decided before
7017
- * the turn ended. A close decided from `onTurnComplete` is past that point, so
7018
- * the client learns from the 409 on its next send.
7019
- */
7020
- async function performChatClose() {
7021
- const request = locals.get(chatCloseRequestedKey);
7022
- if (!request || locals.get(chatClosePerformedKey))
7023
- return;
7024
- const reason = request.reason;
7025
- // Two flags, not one. The record is a client-visible event and must not be
7026
- // written twice, but the row close is the part that actually ends the
7027
- // conversation: flagging it as done before it succeeds would let a transient
7028
- // failure leave the session open with no later call willing to retry.
7029
- if (!locals.get(chatCloseRecordWrittenKey)) {
7030
- locals.set(chatCloseRecordWrittenKey, true);
7031
- try {
7032
- const session = getChatSession();
7033
- await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
7034
- }
7035
- catch (error) {
7036
- logger.warn("chat.close: failed to write the session-closed record", {
7037
- error: error instanceof Error ? error.message : String(error),
7038
- });
7039
- }
7040
- }
7041
- const chatId = locals.get(chatExternalIdKey);
7042
- if (!chatId)
7043
- return;
7044
- try {
7045
- await sessions.close(chatId, {
7046
- ...(reason ? { reason } : {}),
7047
- ...(locals.get(chatAgentRunContextKey)?.run.id
7048
- ? { callingRunId: locals.get(chatAgentRunContextKey).run.id }
7049
- : {}),
7050
- });
7051
- locals.set(chatClosePerformedKey, true);
7052
- }
7053
- catch (error) {
7054
- // Deliberately NOT flagged as performed: the close API is idempotent, so a
7055
- // later exit site on this run gets to retry it. Losing every retry to a
7056
- // transient failure would leave the row open and the conversation alive.
7057
- // Non-fatal either way — the run still exits.
7058
- logger.error("chat.close: failed to close the session", {
7059
- chatId,
7060
- error: error instanceof Error ? error.message : String(error),
7061
- });
7062
- }
7063
- }
7064
6240
  // ---------------------------------------------------------------------------
7065
6241
  // Per-turn deferred work
7066
6242
  // ---------------------------------------------------------------------------
@@ -7122,18 +6298,9 @@ function chatDefer(promiseOrFn) {
7122
6298
  * ```
7123
6299
  */
7124
6300
  function injectBackgroundContext(messages) {
7125
- const systemBlocks = messages.filter((message) => message.role === "system");
7126
- const conversational = messages.filter((message) => message.role !== "system");
7127
- if (systemBlocks.length > 0) {
7128
- const instructions = locals.get(chatInjectedInstructionsKey) ?? [];
7129
- instructions.push(...systemBlocks);
7130
- locals.set(chatInjectedInstructionsKey, instructions);
7131
- }
7132
- if (conversational.length > 0) {
7133
- const queue = locals.get(chatBackgroundQueueKey) ?? [];
7134
- queue.push(...conversational);
7135
- locals.set(chatBackgroundQueueKey, queue);
7136
- }
6301
+ const queue = locals.get(chatBackgroundQueueKey) ?? [];
6302
+ queue.push(...messages);
6303
+ locals.set(chatBackgroundQueueKey, queue);
7137
6304
  }
7138
6305
  // ---------------------------------------------------------------------------
7139
6306
  // Aborted message cleanup
@@ -7546,12 +6713,10 @@ class ChatMessageAccumulator {
7546
6713
  // a duplicate, mirroring the chat.agent accumulator.
7547
6714
  const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
7548
6715
  if (existingIdx !== -1) {
7549
- const previous = this.uiMessages[existingIdx];
7550
6716
  this.uiMessages[existingIdx] = response;
7551
6717
  try {
7552
- if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
7553
- this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7554
- }
6718
+ // Reconvert all model messages since we replaced rather than appended.
6719
+ this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
7555
6720
  }
7556
6721
  catch {
7557
6722
  // Conversion failed — leave the existing model messages in place
@@ -7587,28 +6752,6 @@ class ChatMessageAccumulator {
7587
6752
  const modelMsgs = await toModelMessages([message]);
7588
6753
  this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
7589
6754
  }
7590
- /**
7591
- * Record the messages a steering drain consumed.
7592
- *
7593
- * The drain only puts them in this step's prompt, so without this they
7594
- * shape one answer and then exist in neither lane: not in `uiMessages`,
7595
- * which is what an app persists from, and not in `modelMessages`, which is
7596
- * what every later turn sends.
7597
- *
7598
- * Both lanes are appended to. The model lane is never reconverted from the
7599
- * UI lane, because `compactIfNeeded` replaces it with a summary and leaves
7600
- * the UI lane whole: a reconversion would restore everything the summary
7601
- * replaced.
7602
- */
7603
- async absorbSteering(claimed, injected) {
7604
- const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
7605
- if (fresh.length === 0)
7606
- return;
7607
- this.uiMessages.push(...fresh);
7608
- // Record what the model received. Only when the whole batch is new is
7609
- // `injected` known to describe exactly these messages.
7610
- this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
7611
- }
7612
6755
  /**
7613
6756
  * Get and clear unconsumed steering messages.
7614
6757
  */
@@ -7641,8 +6784,7 @@ class ChatMessageAccumulator {
7641
6784
  }
7642
6785
  // 2. Pending message injection
7643
6786
  if (pm && queue.length > 0) {
7644
- const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7645
- await this.absorbSteering(claimed, injected);
6787
+ const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
7646
6788
  if (injected.length > 0) {
7647
6789
  resultMessages = [...(resultMessages ?? messages), ...injected];
7648
6790
  }
@@ -7831,7 +6973,7 @@ function trackActiveChatSessionIterator(iterator) {
7831
6973
  * ```
7832
6974
  */
7833
6975
  function createChatSession(payload, options) {
7834
- const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
6976
+ const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
7835
6977
  const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
7836
6978
  return {
7837
6979
  [Symbol.asyncIterator]() {
@@ -7920,16 +7062,8 @@ function createChatSession(payload, options) {
7920
7062
  * without suspending.
7921
7063
  */
7922
7064
  if (turn > 0) {
7923
- if (locals.get(chatCloseRequestedKey)) {
7924
- await performChatClose();
7925
- stop.cleanup();
7926
- return { done: true, value: undefined };
7927
- }
7928
7065
  // chat.requestUpgrade() / chat.endRun() — exit before waiting
7929
7066
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
7930
- if (locals.get(chatUpgradeRequestedKey)) {
7931
- await persistUpgradeHandoff();
7932
- }
7933
7067
  stop.cleanup();
7934
7068
  return { done: true, value: undefined };
7935
7069
  }
@@ -8032,7 +7166,6 @@ function createChatSession(payload, options) {
8032
7166
  }
8033
7167
  accumulator.applyHandover(pendingHandoverSignal);
8034
7168
  }
8035
- await followSessionPin(currentPayload.chatId, sessionVersionSkew);
8036
7169
  // chat.requestUpgrade() called before this turn — signal transport and exit
8037
7170
  if (locals.get(chatUpgradeRequestedKey)) {
8038
7171
  await writeUpgradeRequiredChunk();
@@ -8249,8 +7382,7 @@ function createChatSession(payload, options) {
8249
7382
  }
8250
7383
  }
8251
7384
  if (sessionPendingMessages) {
8252
- const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8253
- await accumulator.absorbSteering(claimed, injected);
7385
+ const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
8254
7386
  if (injected.length > 0) {
8255
7387
  resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
8256
7388
  }
@@ -8264,11 +7396,6 @@ function createChatSession(payload, options) {
8264
7396
  async return() {
8265
7397
  activeMsgSub?.off();
8266
7398
  activeMsgSub = undefined;
8267
- // Reached when the consumer leaves the `for await` early (`break`,
8268
- // `return`, a throw). A `chat.close()` from the loop body would
8269
- // otherwise be dropped: the exit that performs it lives in `next()`,
8270
- // and `next()` is never called again.
8271
- await performChatClose();
8272
7399
  // `stop` only exists once next() has booted the iterator.
8273
7400
  stop?.cleanup();
8274
7401
  return { done: true, value: undefined };
@@ -8525,11 +7652,6 @@ function createChatStartSessionAction(taskId, options) {
8525
7652
  const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
8526
7653
  const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
8527
7654
  const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
8528
- // Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
8529
- // action default, which neither truthiness nor `??` would allow.
8530
- const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
8531
- ? params.triggerConfig.externalDeploymentId
8532
- : options?.triggerConfig?.externalDeploymentId;
8533
7655
  const triggerConfig = {
8534
7656
  basePayload: {
8535
7657
  messages: [],
@@ -8556,10 +7678,6 @@ function createChatStartSessionAction(taskId, options) {
8556
7678
  lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
8557
7679
  }
8558
7680
  : {}),
8559
- ...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
8560
- ? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
8561
- : {}),
8562
- ...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
8563
7681
  ...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
8564
7682
  };
8565
7683
  const startBody = {
@@ -8604,7 +7722,6 @@ function createChatStartSessionAction(taskId, options) {
8604
7722
  publicAccessToken,
8605
7723
  runId: created.runId,
8606
7724
  sessionId: created.id,
8607
- ...(created.pendingVersion ? { pendingVersion: true } : {}),
8608
7725
  };
8609
7726
  };
8610
7727
  }
@@ -8638,8 +7755,7 @@ async function callSessionsCreateWithOverride(args) {
8638
7755
  const init = {
8639
7756
  method: "POST",
8640
7757
  headers: overrideRequestHeaders(accessToken),
8641
- // This path bypasses `sessions.start`, so it resolves the pin itself.
8642
- body: JSON.stringify(withResolvedExternalDeploymentId(args.body)),
7758
+ body: JSON.stringify(args.body),
8643
7759
  };
8644
7760
  const response = args.fetchOverride
8645
7761
  ? await args.fetchOverride(url, init, ctx)
@@ -8704,41 +7820,6 @@ async function mintPublicTokenWithOverride(args) {
8704
7820
  expirationTime: args.expirationTime,
8705
7821
  });
8706
7822
  }
8707
- /**
8708
- * Creates a server-side helper that reads a conversation from a transcript
8709
- * storage, for rendering history before the chat connects. Works the same
8710
- * for every storage, the platform default included, so the browser never
8711
- * reads a store directly and the secret key stays on the server.
8712
- *
8713
- * Wrap it in a Next.js server action (or any server-side handler), scope it
8714
- * to the authenticated user through `clientData`, and pass the result to
8715
- * `useLoadTranscript` in the browser.
8716
- *
8717
- * @example
8718
- * ```ts
8719
- * // actions.ts
8720
- * "use server";
8721
- * import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
8722
- *
8723
- * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
8724
- * ```
8725
- */
8726
- function createChatLoadTranscriptAction(storage, options) {
8727
- return async (params) => {
8728
- if (!params.chatId) {
8729
- throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
8730
- }
8731
- if (options?.apiClient) {
8732
- const { apiClient, ...rest } = options;
8733
- return apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
8734
- }
8735
- const limit = params.limit ?? options?.limit;
8736
- return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
8737
- ...(limit !== undefined ? { limit } : {}),
8738
- ...(params.before !== undefined ? { before: params.before } : {}),
8739
- });
8740
- };
8741
- }
8742
7823
  export const chat = {
8743
7824
  /** Create a chat agent. See {@link chatAgent}. */
8744
7825
  agent: chatAgent,
@@ -8750,12 +7831,8 @@ export const chat = {
8750
7831
  withClientData,
8751
7832
  /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
8752
7833
  createStartSessionAction: createChatStartSessionAction,
8753
- /** Returns a server-side helper that reads a conversation from a transcript storage. */
8754
- createLoadTranscriptAction: createChatLoadTranscriptAction,
8755
7834
  /** Pipe a stream to the chat transport. See {@link pipeChat}. */
8756
7835
  pipe: pipeChat,
8757
- /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
8758
- turn: chatTurn,
8759
7836
  /** Create a per-run typed local. See {@link chatLocal}. */
8760
7837
  local: chatLocal,
8761
7838
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
@@ -8776,8 +7853,6 @@ export const chat = {
8776
7853
  endAndContinue,
8777
7854
  /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
8778
7855
  endRun,
8779
- /** End the conversation permanently: close the session and exit the run. See {@link close}. */
8780
- close,
8781
7856
  /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
8782
7857
  cleanupAbortedParts,
8783
7858
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
@@ -8925,16 +8000,6 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8925
8000
  if (consumedCursor !== undefined) {
8926
8001
  extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
8927
8002
  }
8928
- // A close decided before this turn ended rides out on turn-complete. Readers
8929
- // terminate their stream on turn-complete, so a standalone record written
8930
- // after it only reaches a reconnect — this header is what a live client sees.
8931
- const pendingClose = locals.get(chatCloseRequestedKey);
8932
- if (pendingClose) {
8933
- extraHeaders.push([SESSION_CLOSED_HEADER, "true"]);
8934
- if (pendingClose.reason) {
8935
- extraHeaders.push([SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
8936
- }
8937
- }
8938
8003
  const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
8939
8004
  const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
8940
8005
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
@@ -8992,47 +8057,12 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
8992
8057
  *
8993
8058
  * @internal
8994
8059
  */
8995
- /**
8996
- * Persists an upgrade requested after the turn has already run.
8997
- *
8998
- * The pre-turn sites reach {@link performEndAndContinue} through
8999
- * {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
9000
- * session's stored `externalDeploymentId`. The post-turn exits had no such path,
9001
- * so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
9002
- * and every continuation re-pinned to the deployment the agent asked to leave.
9003
- *
9004
- * No `upgrade-required` chunk is written here: the turn already produced its
9005
- * answer, so there is nothing for a client to be told about.
9006
- */
9007
- async function persistUpgradeHandoff() {
9008
- const chatId = locals.get(chatExternalIdKey);
9009
- const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
9010
- if (!chatId || !callingRunId) {
9011
- return;
9012
- }
9013
- try {
9014
- await performEndAndContinue({
9015
- reason: "upgrade",
9016
- externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9017
- });
9018
- }
9019
- catch (error) {
9020
- logger.warn("upgrade handoff failed; session keeps its current version pin", {
9021
- chatId,
9022
- callingRunId,
9023
- error,
9024
- });
9025
- }
9026
- }
9027
8060
  async function writeUpgradeRequiredChunk() {
9028
8061
  const chatId = locals.get(chatExternalIdKey);
9029
8062
  const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
9030
8063
  if (chatId && callingRunId) {
9031
8064
  try {
9032
- await performEndAndContinue({
9033
- reason: "upgrade",
9034
- externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
9035
- });
8065
+ await performEndAndContinue();
9036
8066
  }
9037
8067
  catch (error) {
9038
8068
  // Non-fatal: the next `.in/append` re-triggers via the probe.