@trigger.dev/sdk 4.5.15 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +2 -0
  3. package/dist/commonjs/v3/ai-shared.d.ts +15 -0
  4. package/dist/commonjs/v3/ai-shared.js +35 -1
  5. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  6. package/dist/commonjs/v3/ai.d.ts +311 -25
  7. package/dist/commonjs/v3/ai.js +1633 -380
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/auth.d.ts +10 -4
  10. package/dist/commonjs/v3/auth.js.map +1 -1
  11. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  12. package/dist/commonjs/v3/chat-client.js +227 -111
  13. package/dist/commonjs/v3/chat-client.js.map +1 -1
  14. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  15. package/dist/commonjs/v3/chat-react.js +130 -1
  16. package/dist/commonjs/v3/chat-react.js.map +1 -1
  17. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  18. package/dist/commonjs/v3/chat-server.js +52 -7
  19. package/dist/commonjs/v3/chat-server.js.map +1 -1
  20. package/dist/commonjs/v3/chat.d.ts +92 -10
  21. package/dist/commonjs/v3/chat.js +328 -73
  22. package/dist/commonjs/v3/chat.js.map +1 -1
  23. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  24. package/dist/commonjs/v3/chatSnapshotIo.js +173 -0
  25. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  26. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  27. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  28. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  29. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  30. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  31. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  32. package/dist/commonjs/v3/retry.d.ts +4 -0
  33. package/dist/commonjs/v3/retry.js +22 -13
  34. package/dist/commonjs/v3/retry.js.map +1 -1
  35. package/dist/commonjs/v3/sessions.d.ts +15 -2
  36. package/dist/commonjs/v3/sessions.js +12 -1
  37. package/dist/commonjs/v3/sessions.js.map +1 -1
  38. package/dist/commonjs/v3/shared.js +30 -36
  39. package/dist/commonjs/v3/shared.js.map +1 -1
  40. package/dist/commonjs/v3/test/index.d.ts +1 -0
  41. package/dist/commonjs/v3/test/index.js +3 -1
  42. package/dist/commonjs/v3/test/index.js.map +1 -1
  43. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  44. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  45. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  46. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  47. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  48. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  49. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  50. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  51. package/dist/commonjs/v3/transcriptStorage.d.ts +261 -0
  52. package/dist/commonjs/v3/transcriptStorage.js +374 -0
  53. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  54. package/dist/commonjs/version.js +1 -1
  55. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  56. package/dist/esm/imports/ai-runtime.js +2 -2
  57. package/dist/esm/imports/ai-runtime.js.map +1 -1
  58. package/dist/esm/v3/ai-shared.d.ts +15 -0
  59. package/dist/esm/v3/ai-shared.js +33 -0
  60. package/dist/esm/v3/ai-shared.js.map +1 -1
  61. package/dist/esm/v3/ai.d.ts +311 -25
  62. package/dist/esm/v3/ai.js +1625 -377
  63. package/dist/esm/v3/ai.js.map +1 -1
  64. package/dist/esm/v3/auth.d.ts +10 -4
  65. package/dist/esm/v3/auth.js.map +1 -1
  66. package/dist/esm/v3/chat-client.d.ts +11 -2
  67. package/dist/esm/v3/chat-client.js +228 -112
  68. package/dist/esm/v3/chat-client.js.map +1 -1
  69. package/dist/esm/v3/chat-react.d.ts +88 -0
  70. package/dist/esm/v3/chat-react.js +127 -1
  71. package/dist/esm/v3/chat-react.js.map +1 -1
  72. package/dist/esm/v3/chat-server.d.ts +42 -6
  73. package/dist/esm/v3/chat-server.js +53 -8
  74. package/dist/esm/v3/chat-server.js.map +1 -1
  75. package/dist/esm/v3/chat.d.ts +92 -10
  76. package/dist/esm/v3/chat.js +329 -74
  77. package/dist/esm/v3/chat.js.map +1 -1
  78. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  79. package/dist/esm/v3/chatSnapshotIo.js +165 -0
  80. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  81. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  82. package/dist/esm/v3/chatVersionSkew.js +27 -0
  83. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  84. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  85. package/dist/esm/v3/externalDeploymentId.js +38 -0
  86. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  87. package/dist/esm/v3/retry.d.ts +4 -0
  88. package/dist/esm/v3/retry.js +14 -8
  89. package/dist/esm/v3/retry.js.map +1 -1
  90. package/dist/esm/v3/sessions.d.ts +15 -2
  91. package/dist/esm/v3/sessions.js +11 -1
  92. package/dist/esm/v3/sessions.js.map +1 -1
  93. package/dist/esm/v3/shared.js +17 -23
  94. package/dist/esm/v3/shared.js.map +1 -1
  95. package/dist/esm/v3/test/index.d.ts +1 -0
  96. package/dist/esm/v3/test/index.js +1 -0
  97. package/dist/esm/v3/test/index.js.map +1 -1
  98. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  99. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  100. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  101. package/dist/esm/v3/test/test-session-handle.js +6 -0
  102. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  103. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  104. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  105. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  106. package/dist/esm/v3/transcriptStorage.d.ts +261 -0
  107. package/dist/esm/v3/transcriptStorage.js +360 -0
  108. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  109. package/dist/esm/version.js +1 -1
  110. package/docs/ai-chat/actions.mdx +55 -23
  111. package/docs/ai-chat/anatomy.mdx +47 -27
  112. package/docs/ai-chat/backend.mdx +125 -48
  113. package/docs/ai-chat/background-injection.mdx +122 -19
  114. package/docs/ai-chat/client-protocol.mdx +5 -4
  115. package/docs/ai-chat/compaction.mdx +13 -7
  116. package/docs/ai-chat/custom-agents.mdx +23 -0
  117. package/docs/ai-chat/fast-starts.mdx +27 -20
  118. package/docs/ai-chat/frontend.mdx +19 -14
  119. package/docs/ai-chat/how-it-works.mdx +1 -1
  120. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  121. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  122. package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
  123. package/docs/ai-chat/patterns/database-persistence.mdx +24 -9
  124. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  125. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  126. package/docs/ai-chat/patterns/skills.mdx +7 -10
  127. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  128. package/docs/ai-chat/pending-messages.mdx +3 -3
  129. package/docs/ai-chat/prompt-caching.mdx +23 -25
  130. package/docs/ai-chat/quick-start.mdx +11 -11
  131. package/docs/ai-chat/reference.mdx +69 -10
  132. package/docs/ai-chat/sessions.mdx +6 -1
  133. package/docs/ai-chat/side-channels.mdx +1 -1
  134. package/docs/ai-chat/testing.mdx +2 -1
  135. package/docs/ai-chat/tools.mdx +18 -13
  136. package/docs/ai-chat/transcript-storage.mdx +247 -0
  137. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  138. package/docs/apikeys.mdx +27 -45
  139. package/docs/config/extensions/custom.mdx +14 -1
  140. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  141. package/docs/deploy-environment-variables.mdx +23 -2
  142. package/docs/deployment/overview.mdx +4 -8
  143. package/docs/deployment/preview-branches.mdx +4 -4
  144. package/docs/deployment/version-skew-protection.mdx +62 -0
  145. package/docs/github-integration.mdx +1 -1
  146. package/docs/introduction.mdx +58 -152
  147. package/docs/manual-setup.mdx +7 -7
  148. package/docs/mcp-tools.mdx +9 -0
  149. package/docs/quick-start.mdx +3 -3
  150. package/docs/realtime/auth.mdx +1 -1
  151. package/docs/reports.mdx +157 -0
  152. package/docs/self-hosting/security.mdx +17 -0
  153. package/docs/tasks/scheduled.mdx +24 -0
  154. package/docs/triggering.mdx +1 -1
  155. package/docs/troubleshooting-zod.mdx +158 -0
  156. package/docs/troubleshooting.mdx +1 -1
  157. package/package.json +4 -4
  158. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  159. package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
@@ -44,6 +44,32 @@ function isAuthError(error) {
44
44
  const e = error;
45
45
  return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
46
46
  }
47
+ /**
48
+ * Detect the closed-session refusal from `.in/append`: HTTP 409 with the
49
+ * stable `session_closed` code. Terminal — never retried.
50
+ */
51
+ function isSessionClosedError(error) {
52
+ if (error === null || typeof error !== "object")
53
+ return false;
54
+ const e = error;
55
+ return e.name === "TriggerApiError" && e.status === 409 && e.code === "session_closed";
56
+ }
57
+ function sessionClosedReasonOf(error) {
58
+ if (error === null || typeof error !== "object")
59
+ return undefined;
60
+ const reason = error.closedReason;
61
+ return typeof reason === "string" ? reason : undefined;
62
+ }
63
+ /** The local form of the refusal, for sends the transport rejects itself. */
64
+ function sessionClosedError(chatId, reason) {
65
+ const err = new Error(reason ? `Chat session ${chatId} is closed: ${reason}` : `Chat session ${chatId} is closed`);
66
+ err.name = "TriggerApiError";
67
+ err.status = 409;
68
+ err.code = "session_closed";
69
+ if (reason)
70
+ err.closedReason = reason;
71
+ return err;
72
+ }
47
73
  /**
48
74
  * Detect a 404 from a session-PAT-authed call — i.e. the session doesn't
49
75
  * exist in the current environment. Happens when hydrated session state
@@ -149,6 +175,7 @@ class TriggerChatTransport {
149
175
  _onSessionChange;
150
176
  _onEvent;
151
177
  sessions = new Map();
178
+ pendingResumeCursors = new Map();
152
179
  activeStreams = new Map();
153
180
  pendingStarts = new Map();
154
181
  // Last turn-producing send per chat — attribution source for the
@@ -188,6 +215,8 @@ class TriggerChatTransport {
188
215
  lastEventId: session.lastEventId,
189
216
  activeInputSeq: session.activeInputSeq,
190
217
  isStreaming: session.isStreaming,
218
+ closed: session.closed,
219
+ closedReason: session.closedReason,
191
220
  });
192
221
  }
193
222
  }
@@ -237,6 +266,10 @@ class TriggerChatTransport {
237
266
  */
238
267
  sendMessages = async (options) => {
239
268
  const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options;
269
+ const closedState = this.sessions.get(chatId);
270
+ if (closedState?.closed) {
271
+ throw sessionClosedError(chatId, closedState.closedReason);
272
+ }
240
273
  if (this.coordinator) {
241
274
  if (this.coordinator.isReadOnly(chatId)) {
242
275
  throw new Error("This chat is active in another tab");
@@ -246,6 +279,14 @@ class TriggerChatTransport {
246
279
  const mergedMetadata = this.defaultMetadata || metadata
247
280
  ? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) }
248
281
  : undefined;
282
+ // An action sent through `useChat`. `useChatActions` (or any caller) puts
283
+ // it in `body.action`; sending it here rather than through
284
+ // `transport.sendAction` means `useChat` owns the response stream, so an
285
+ // action that becomes a turn renders the way a message turn does.
286
+ const actionInBody = body?.action;
287
+ if (actionInBody !== undefined) {
288
+ return this.sendAction(chatId, actionInBody, { abortSignal, metadata: mergedMetadata });
289
+ }
249
290
  // First-turn handover routing — when `headStart` is set AND no
250
291
  // session state exists yet for this chatId, POST the wire payload
251
292
  // to the customer's `chat.handover` route handler. The handler
@@ -308,11 +349,9 @@ class TriggerChatTransport {
308
349
  activeStream.abort();
309
350
  this.activeStreams.delete(chatId);
310
351
  }
311
- // A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
312
- // turn would be skipped record by record.
313
- state.skipToTurnComplete = false;
314
352
  state.activeInputSeq = inSeq;
315
353
  state.isStreaming = true;
354
+ state.outstandingTurnAbandoned = false;
316
355
  this.notifySessionChange(chatId, state);
317
356
  // Owning turn: aborting this live send stops the turn the user drives.
318
357
  return this.subscribeToSessionStream(state, abortSignal, chatId, {
@@ -384,6 +423,16 @@ class TriggerChatTransport {
384
423
  };
385
424
  this.sessions.set(chatId, state);
386
425
  this.notifySessionChange(chatId, state);
426
+ // Step 1 streams from the warm server either way; this says the agent run that owes step 2
427
+ // is parked on an undeployed external deployment id.
428
+ if (response.headers.get("X-Trigger-Chat-Pending-Version") === "1") {
429
+ this.emitEvent({
430
+ type: "run-pending-version",
431
+ chatId,
432
+ timestamp: Date.now(),
433
+ source: "head-start",
434
+ });
435
+ }
387
436
  // Filter the parsed UIMessage stream:
388
437
  // - Drop control chunks (`trigger:turn-complete`,
389
438
  // `trigger:session-state`) before they reach AI SDK — they
@@ -550,6 +599,9 @@ class TriggerChatTransport {
550
599
  const state = this.sessions.get(options.chatId);
551
600
  if (!state)
552
601
  return null;
602
+ // A closed session has no further turns to resume.
603
+ if (state.closed)
604
+ return null;
553
605
  // Watch is a standing subscription: a settled session is exactly the
554
606
  // state it waits in, so a completed last turn must not block the resume.
555
607
  if (state.isStreaming === false && !this.watchMode)
@@ -594,7 +646,13 @@ class TriggerChatTransport {
594
646
  catch {
595
647
  return false;
596
648
  }
597
- state.skipToTurnComplete = true;
649
+ // Only gate when a sent turn is still outstanding. A stop at a boundary has
650
+ // nothing to supersede, and gating it would swallow the next turn.
651
+ if (!state.outstandingTurnAbandoned &&
652
+ (state.isStreaming || state.activeInputSeq !== undefined)) {
653
+ state.skipToTurnComplete = true;
654
+ state.supersededInputSeq = state.activeInputSeq;
655
+ }
598
656
  const activeStream = this.activeStreams.get(chatId);
599
657
  if (activeStream) {
600
658
  activeStream.abort();
@@ -613,15 +671,28 @@ class TriggerChatTransport {
613
671
  this.notifySessionChange(chatId, state);
614
672
  return true;
615
673
  };
674
+ /**
675
+ * Clear the supersede gate armed by a stop. Call this when the stopped
676
+ * turn died without ever writing its `trigger:turn-complete` boundary,
677
+ * otherwise the next turn stays gated forever.
678
+ */
679
+ clearSupersedeGate = (chatId) => {
680
+ const state = this.sessions.get(chatId);
681
+ if (!state)
682
+ return;
683
+ state.skipToTurnComplete = false;
684
+ state.supersededInputSeq = undefined;
685
+ state.outstandingTurnAbandoned = true;
686
+ };
616
687
  /**
617
688
  * Send a custom action chunk (for `chat.agent`'s `actionSchema` /
618
- * `onAction` hook). Actions are not turns only `hydrateMessages`
619
- * and `onAction` fire on the agent side. The returned stream
620
- * carries any model response `onAction` produced (when it returns a
621
- * `StreamTextResult`); for `void`-returning side-effect-only actions
622
- * the stream completes immediately with `trigger:turn-complete`.
689
+ * `onAction` hook). An action is an edit: only `hydrateMessages` and
690
+ * `onAction` fire on the agent side, and the returned stream completes
691
+ * with `trigger:turn-complete` once the edit is persisted. When `onAction`
692
+ * returns `chat.turn()` the turn's response follows on the same stream.
693
+ * Per-action `metadata` is merged over the transport's `clientData`.
623
694
  */
624
- sendAction = async (chatId, action) => {
695
+ sendAction = async (chatId, action, options) => {
625
696
  if (this.coordinator) {
626
697
  if (this.coordinator.isReadOnly(chatId)) {
627
698
  throw new Error("This chat is active in another tab");
@@ -633,7 +704,9 @@ class TriggerChatTransport {
633
704
  chatId,
634
705
  trigger: "action",
635
706
  action,
636
- metadata: this.defaultMetadata ?? undefined,
707
+ metadata: this.defaultMetadata || options?.metadata
708
+ ? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
709
+ : undefined,
637
710
  };
638
711
  const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });
639
712
  const partId = crypto.randomUUID();
@@ -648,16 +721,14 @@ class TriggerChatTransport {
648
721
  activeStream.abort();
649
722
  this.activeStreams.delete(chatId);
650
723
  }
651
- // A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
652
- // turn would be skipped record by record.
653
- state.skipToTurnComplete = false;
654
724
  // Mark streaming + persist so a reload mid-action resumes (reconnectToStream
655
725
  // no-ops when the persisted session says isStreaming: false).
656
726
  state.activeInputSeq = inSeq;
657
727
  state.isStreaming = true;
728
+ state.outstandingTurnAbandoned = false;
658
729
  this.notifySessionChange(chatId, state);
659
730
  // Owning action: aborting this send stops the turn the user drives.
660
- return this.subscribeToSessionStream(state, undefined, chatId, {
731
+ return this.subscribeToSessionStream(state, options?.abortSignal, chatId, {
661
732
  sinceInSeq: inSeq,
662
733
  sendStopOnAbort: true,
663
734
  });
@@ -672,14 +743,42 @@ class TriggerChatTransport {
672
743
  return this.toPersisted(state);
673
744
  };
674
745
  setSession(chatId, session) {
675
- this.sessions.set(chatId, {
746
+ this.sessions.set(chatId, this.applyPendingResumeCursor(chatId, {
676
747
  publicAccessToken: session.publicAccessToken,
677
748
  lastEventId: session.lastEventId,
678
749
  activeInputSeq: session.activeInputSeq,
679
750
  isStreaming: session.isStreaming,
680
- });
751
+ }));
681
752
  this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)));
682
753
  }
754
+ /**
755
+ * Seed the `.out` resume cursor from a loaded transcript. Applied to the
756
+ * session now if it exists, otherwise held until the session is created so
757
+ * the first live subscription opens past the persisted history instead of
758
+ * replaying it. Never moves an existing cursor backward: the transcript load
759
+ * is async, so a live `.out` record can already have advanced the session
760
+ * past the snapshot, and overwriting it would replay those records.
761
+ */
762
+ seedResumeCursor = (chatId, lastEventId) => {
763
+ const existing = this.sessions.get(chatId);
764
+ if (existing?.publicAccessToken) {
765
+ if (existing.lastEventId === undefined) {
766
+ existing.lastEventId = lastEventId;
767
+ this.notifySessionChange(chatId, this.toPersisted(existing));
768
+ }
769
+ this.pendingResumeCursors.delete(chatId);
770
+ return;
771
+ }
772
+ this.pendingResumeCursors.set(chatId, lastEventId);
773
+ };
774
+ applyPendingResumeCursor(chatId, state) {
775
+ const pending = this.pendingResumeCursors.get(chatId);
776
+ if (pending !== undefined && state.lastEventId === undefined) {
777
+ state.lastEventId = pending;
778
+ }
779
+ this.pendingResumeCursors.delete(chatId);
780
+ return state;
781
+ }
683
782
  setOnSessionChange(callback) {
684
783
  this._onSessionChange = callback;
685
784
  }
@@ -750,6 +849,22 @@ class TriggerChatTransport {
750
849
  setClientData(clientData) {
751
850
  this.defaultMetadata = clientData;
752
851
  }
852
+ /**
853
+ * Update the request-time callbacks. Same reason as `setClientData`:
854
+ * a host that rebuilds these per render (a dashboard navigating between
855
+ * projects) would otherwise keep hitting the endpoint captured at
856
+ * construction. Every read goes through the live field, so the next
857
+ * request uses the latest callback.
858
+ */
859
+ setAccessToken(accessToken) {
860
+ this.resolveAccessToken = accessToken;
861
+ }
862
+ setStartSession(startSession) {
863
+ this.resolveStartSession = startSession;
864
+ }
865
+ setFetch(fetchOverride) {
866
+ this.fetchOverride = fetchOverride;
867
+ }
753
868
  // -------------------------------------------------------------------------
754
869
  // Multi-tab coordination passthrough
755
870
  // -------------------------------------------------------------------------
@@ -797,7 +912,42 @@ class TriggerChatTransport {
797
912
  lastEventId: state.lastEventId,
798
913
  activeInputSeq: state.activeInputSeq,
799
914
  isStreaming: state.isStreaming,
915
+ closed: state.closed,
916
+ closedReason: state.closedReason,
800
917
  });
918
+ /**
919
+ * Flip a session to closed and tell listeners once. Terminal: sends and
920
+ * reconnects stop from here on, and there is no reopening.
921
+ */
922
+ markSessionClosed(chatId, state, reason, source) {
923
+ if (state.closed)
924
+ return;
925
+ state.closed = true;
926
+ if (reason)
927
+ state.closedReason = reason;
928
+ state.isStreaming = false;
929
+ state.activeInputSeq = undefined;
930
+ state.skipToTurnComplete = false;
931
+ state.supersededInputSeq = undefined;
932
+ this.emitEvent({
933
+ type: "session-closed",
934
+ chatId,
935
+ timestamp: Date.now(),
936
+ ...(reason ? { reason } : {}),
937
+ source,
938
+ });
939
+ this.notifySessionChange(chatId, state);
940
+ this.coordinator?.release(chatId);
941
+ }
942
+ /**
943
+ * Whether the conversation has been closed — by the agent via
944
+ * `chat.close()`, or from outside via `sessions.close()`. Closed is
945
+ * one-way: the transport refuses further sends and reconnects, and the
946
+ * transcript stays readable.
947
+ */
948
+ sessionStatus = (chatId) => this.sessions.get(chatId)?.closed ? "closed" : "open";
949
+ /** The reason the session was closed, when one was given. */
950
+ sessionClosedReason = (chatId) => this.sessions.get(chatId)?.closedReason;
801
951
  notifySessionChange(chatId, session) {
802
952
  if (!this._onSessionChange)
803
953
  return;
@@ -832,7 +982,7 @@ class TriggerChatTransport {
832
982
  // `sessions: { ... }` already, or the very first `accessToken` call
833
983
  // returns a PAT for an out-of-band-created session.
834
984
  const token = await this.resolveAccessToken({ chatId });
835
- const state = { publicAccessToken: token };
985
+ const state = this.applyPendingResumeCursor(chatId, { publicAccessToken: token });
836
986
  this.sessions.set(chatId, state);
837
987
  this.notifySessionChange(chatId, state);
838
988
  return state;
@@ -841,15 +991,23 @@ class TriggerChatTransport {
841
991
  if (!this.resolveStartSession) {
842
992
  throw new Error("TriggerChatTransport: `startSession` is required to call `start()` / `preload()`. Either provide it or pre-hydrate the session via `sessions: { ... }`.");
843
993
  }
844
- const { publicAccessToken } = await this.resolveStartSession({
994
+ const { publicAccessToken, pendingVersion } = await this.resolveStartSession({
845
995
  taskId: this.taskId,
846
996
  chatId,
847
997
  clientData: (this.defaultMetadata ?? {}),
848
998
  });
849
- const state = {
999
+ if (pendingVersion) {
1000
+ this.emitEvent({
1001
+ type: "run-pending-version",
1002
+ chatId,
1003
+ timestamp: Date.now(),
1004
+ source: "start",
1005
+ });
1006
+ }
1007
+ const state = this.applyPendingResumeCursor(chatId, {
850
1008
  publicAccessToken,
851
1009
  isStreaming: false,
852
- };
1010
+ });
853
1011
  this.sessions.set(chatId, state);
854
1012
  this.notifySessionChange(chatId, state);
855
1013
  return state;
@@ -862,6 +1020,33 @@ class TriggerChatTransport {
862
1020
  const raw = this.resolveBaseURLFn(ctx);
863
1021
  return raw.replace(/\/$/, "");
864
1022
  }
1023
+ /**
1024
+ * `fetchClient` for an `out` subscription. Reads `this.fetchOverride` on
1025
+ * every call, not once per subscription: a watch-mode subscription
1026
+ * reconnects for the lifetime of the page, and `setFetch` may have swapped
1027
+ * the override in between.
1028
+ */
1029
+ sseFetchClient(ctx) {
1030
+ return ((input, init) => {
1031
+ const override = this.fetchOverride;
1032
+ if (!override)
1033
+ return fetch(input, init);
1034
+ if (typeof input === "string") {
1035
+ return override(input, init ?? {}, ctx);
1036
+ }
1037
+ if (input instanceof URL) {
1038
+ return override(input.toString(), init ?? {}, ctx);
1039
+ }
1040
+ // Request — preserve its url + intrinsic init, let any provided init
1041
+ // override on top (matches fetch(Request, init) semantics).
1042
+ return override(input.url, {
1043
+ method: input.method,
1044
+ headers: input.headers,
1045
+ signal: input.signal,
1046
+ ...(init ?? {}),
1047
+ }, ctx);
1048
+ });
1049
+ }
865
1050
  async doFetch(ctx, url, init) {
866
1051
  return this.fetchOverride ? this.fetchOverride(url, init, ctx) : fetch(url, init);
867
1052
  }
@@ -883,24 +1068,65 @@ class TriggerChatTransport {
883
1068
  const err = new Error(`appendToSessionStream failed: ${response.status} ${text}`);
884
1069
  err.name = "TriggerApiError";
885
1070
  err.status = response.status;
1071
+ let parsed;
1072
+ try {
1073
+ parsed = JSON.parse(text);
1074
+ }
1075
+ catch {
1076
+ parsed = undefined;
1077
+ }
1078
+ if (typeof parsed?.code === "string")
1079
+ err.code = parsed.code;
1080
+ if (typeof parsed?.closedReason === "string")
1081
+ err.closedReason = parsed.closedReason;
886
1082
  throw err;
887
1083
  }
888
1084
  // The appended record's `.in` seq, for correlating the response stream to
889
1085
  // this send. Omitted by older webapps / a lost idempotency claim.
890
1086
  const data = (await response.json().catch(() => undefined));
1087
+ if (data?.pendingVersion === true) {
1088
+ this.emitEvent({
1089
+ type: "run-pending-version",
1090
+ chatId,
1091
+ timestamp: Date.now(),
1092
+ source: "send",
1093
+ });
1094
+ }
891
1095
  return typeof data?.seq === "number" ? data.seq : undefined;
892
1096
  }
893
1097
  async callWithAuthRetry(chatId, state, op) {
1098
+ if (state.closed) {
1099
+ throw sessionClosedError(chatId, state.closedReason);
1100
+ }
1101
+ // Every attempt goes through here, so a closed-session refusal is terminal
1102
+ // on whichever one hits it. Reaching a 409 from a later attempt and not
1103
+ // marking it would leave local status open on a conversation the server
1104
+ // has already ended.
1105
+ const attempt = async (token) => {
1106
+ try {
1107
+ return await op(token);
1108
+ }
1109
+ catch (err) {
1110
+ if (isSessionClosedError(err)) {
1111
+ // Terminal. Retrying, refreshing the PAT, or recreating the session
1112
+ // would all be wrong: the conversation is over.
1113
+ this.markSessionClosed(chatId, state, sessionClosedReasonOf(err), "append");
1114
+ }
1115
+ throw err;
1116
+ }
1117
+ };
894
1118
  // 1) Try with the current PAT.
895
1119
  try {
896
- return await op(state.publicAccessToken);
1120
+ return await attempt(state.publicAccessToken);
897
1121
  }
898
1122
  catch (err) {
1123
+ if (isSessionClosedError(err))
1124
+ throw err;
899
1125
  if (isSessionNotFoundError(err)) {
900
1126
  // The cached PAT authenticated but the session doesn't exist here —
901
1127
  // recreate it and retry.
902
1128
  await this.recreateSession(chatId, state);
903
- return await op(state.publicAccessToken);
1129
+ return await attempt(state.publicAccessToken);
904
1130
  }
905
1131
  if (!isAuthError(err))
906
1132
  throw err;
@@ -911,7 +1137,7 @@ class TriggerChatTransport {
911
1137
  state.publicAccessToken = fresh;
912
1138
  this.notifySessionChange(chatId, state);
913
1139
  try {
914
- return await op(fresh);
1140
+ return await attempt(fresh);
915
1141
  }
916
1142
  catch (err) {
917
1143
  if (!isSessionNotFoundError(err))
@@ -921,7 +1147,7 @@ class TriggerChatTransport {
921
1147
  // state is stale (created in a different environment, or before the
922
1148
  // sessions upgrade). Recreate the session and retry once.
923
1149
  await this.recreateSession(chatId, state);
924
- return await op(state.publicAccessToken);
1150
+ return await attempt(state.publicAccessToken);
925
1151
  }
926
1152
  /**
927
1153
  * Repair a stale/phantom hydrated session. The cached state pointed at a
@@ -937,11 +1163,19 @@ class TriggerChatTransport {
937
1163
  if (!this.resolveStartSession) {
938
1164
  throw new Error("TriggerChatTransport: session not found and no `startSession` configured to recreate it. The stored session state for this chat may be stale (e.g. created in a different environment) — provide `startSession` or clear the stored session so a fresh one can be created.");
939
1165
  }
940
- const { publicAccessToken } = await this.resolveStartSession({
1166
+ const { publicAccessToken, pendingVersion } = await this.resolveStartSession({
941
1167
  taskId: this.taskId,
942
1168
  chatId,
943
1169
  clientData: (this.defaultMetadata ?? {}),
944
1170
  });
1171
+ if (pendingVersion) {
1172
+ this.emitEvent({
1173
+ type: "run-pending-version",
1174
+ chatId,
1175
+ timestamp: Date.now(),
1176
+ source: "start",
1177
+ });
1178
+ }
945
1179
  state.publicAccessToken = publicAccessToken;
946
1180
  state.lastEventId = undefined;
947
1181
  state.isStreaming = false;
@@ -965,8 +1199,13 @@ class TriggerChatTransport {
965
1199
  : internalAbort.signal;
966
1200
  if (abortSignal) {
967
1201
  abortSignal.addEventListener("abort", () => {
968
- if (options?.sendStopOnAbort !== false) {
1202
+ // A late abort (unmount, or the consumer dropping a drained stream)
1203
+ // has no turn to stop: don't gate the next one, don't write a stop.
1204
+ const outstanding = !state.outstandingTurnAbandoned &&
1205
+ (state.isStreaming || state.activeInputSeq !== undefined);
1206
+ if (options?.sendStopOnAbort !== false && outstanding && !internalAbort.signal.aborted) {
969
1207
  state.skipToTurnComplete = true;
1208
+ state.supersededInputSeq = state.activeInputSeq;
970
1209
  this.appendInputChunk(chatId, state.publicAccessToken, this.serializeInputChunk({ kind: "stop" })).catch(() => { });
971
1210
  }
972
1211
  internalAbort.abort();
@@ -1026,26 +1265,7 @@ class TriggerChatTransport {
1026
1265
  })()
1027
1266
  : () => { };
1028
1267
  const sseCtx = { endpoint: "out", chatId };
1029
- const fetchOverride = this.fetchOverride;
1030
- const sseFetchClient = fetchOverride
1031
- ? ((input, init) => {
1032
- if (typeof input === "string") {
1033
- return fetchOverride(input, init ?? {}, sseCtx);
1034
- }
1035
- if (input instanceof URL) {
1036
- return fetchOverride(input.toString(), init ?? {}, sseCtx);
1037
- }
1038
- // Request — preserve its url + intrinsic init, let any
1039
- // provided init override on top (matches fetch(Request, init)
1040
- // semantics).
1041
- return fetchOverride(input.url, {
1042
- method: input.method,
1043
- headers: input.headers,
1044
- signal: input.signal,
1045
- ...(init ?? {}),
1046
- }, sseCtx);
1047
- })
1048
- : undefined;
1268
+ const sseFetchClient = this.sseFetchClient(sseCtx);
1049
1269
  let sawFirstChunk = false;
1050
1270
  let sinceInSeq = options?.sinceInSeq;
1051
1271
  const connectSseOnce = async (token) => {
@@ -1093,36 +1313,16 @@ class TriggerChatTransport {
1093
1313
  return await connectSseOnce(fresh);
1094
1314
  }
1095
1315
  };
1096
- // A body that ends without a turn-complete is only terminal when the
1097
- // server says the session settled — otherwise the turn is still
1098
- // running and we lost the connection (long-poll window closed, proxy
1099
- // restarted). Resubscribe from `state.lastEventId`, bounded so a
1100
- // permanently empty stream can't spin.
1101
- const MAX_EOF_RESUBSCRIBES = 5;
1102
1316
  let eofResubscribes = 0;
1103
1317
  const resumeAfterEof = async () => {
1104
1318
  // Watch mode is a standing subscription: it outlives turn-complete
1105
1319
  // (which clears `isStreaming`) and idle windows EOF by design, so the
1106
1320
  // give-up budget doesn't apply. Only abort or a settled session ends it.
1107
- while ((this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) &&
1321
+ while ((this.watchMode || (state.isStreaming && eofResubscribes < ai_shared_js_1.MAX_EOF_RESUBSCRIBES)) &&
1108
1322
  !currentSubscription?.sessionSettled &&
1109
1323
  !combinedSignal.aborted) {
1110
1324
  eofResubscribes++;
1111
- // Sleep, but wake immediately on abort — otherwise a stop lands
1112
- // mid-backoff and the stream stays open for the rest of it.
1113
- await new Promise((resolve) => {
1114
- let timer;
1115
- const done = () => {
1116
- clearTimeout(timer);
1117
- combinedSignal.removeEventListener("abort", done);
1118
- resolve();
1119
- };
1120
- // Jitter the backoff so many clients reconnecting after the same
1121
- // dropped window don't resubscribe in lockstep.
1122
- const backoff = Math.min(100 * 2 ** (eofResubscribes - 1), 5_000);
1123
- timer = setTimeout(done, backoff * (0.5 + Math.random() * 0.5));
1124
- combinedSignal.addEventListener("abort", done);
1125
- });
1325
+ await (0, ai_shared_js_1.waitBeforeEofResubscribe)(eofResubscribes, combinedSignal);
1126
1326
  if (combinedSignal.aborted)
1127
1327
  break;
1128
1328
  const opened = await openWithAuthRetry();
@@ -1246,11 +1446,41 @@ class TriggerChatTransport {
1246
1446
  continue;
1247
1447
  }
1248
1448
  }
1249
- if (state.skipToTurnComplete) {
1250
- if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1251
- state.skipToTurnComplete = false;
1449
+ if (state.skipToTurnComplete &&
1450
+ controlValue !== v3_1.TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED &&
1451
+ controlValue !== v3_1.TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION) {
1452
+ // Fall through on a boundary so the `sinceInSeq` check and the
1453
+ // turn-completed emit below still run; content records are skipped.
1454
+ // A close is terminal and a pending version is the gated turn's own
1455
+ // handover status, so neither is ever gated away.
1456
+ if (controlValue !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
1457
+ continue;
1458
+ // A boundary older than the superseded turn is not its boundary —
1459
+ // keep gating. A header-less boundary (legacy wire) still clears.
1460
+ const gateCursorRaw = (0, v3_1.headerValue)(value.headers, v3_1.SESSION_IN_EVENT_ID_HEADER);
1461
+ const gateCursor = gateCursorRaw !== undefined ? Number.parseInt(gateCursorRaw, 10) : NaN;
1462
+ if (state.supersededInputSeq !== undefined &&
1463
+ !Number.isNaN(gateCursor) &&
1464
+ gateCursor < state.supersededInputSeq) {
1465
+ continue;
1466
+ }
1467
+ state.skipToTurnComplete = false;
1468
+ state.supersededInputSeq = undefined;
1469
+ // This boundary is the new turn's own, so the gate swallowed its
1470
+ // output: fail the turn instead of completing an empty answer, and
1471
+ // leave nothing armed for the retry.
1472
+ if (sinceInSeq !== undefined &&
1473
+ !Number.isNaN(gateCursor) &&
1474
+ gateCursor >= sinceInSeq) {
1475
+ // The lost boundary may also carry the session close.
1476
+ if ((0, v3_1.headerValue)(value.headers, v3_1.SESSION_CLOSED_HEADER) === "true") {
1477
+ this.markSessionClosed(chatId, state, (0, v3_1.headerValue)(value.headers, v3_1.SESSION_CLOSED_REASON_HEADER), "stream");
1478
+ }
1479
+ state.activeInputSeq = undefined;
1480
+ state.isStreaming = false;
1481
+ this.notifySessionChange(chatId, state);
1482
+ throw new Error("The previous turn's output was lost. Try again.");
1252
1483
  }
1253
- continue;
1254
1484
  }
1255
1485
  if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED) {
1256
1486
  // Server has already triggered the new run via
@@ -1259,6 +1489,26 @@ class TriggerChatTransport {
1259
1489
  // and keep reading.
1260
1490
  continue;
1261
1491
  }
1492
+ if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED) {
1493
+ this.markSessionClosed(chatId, state, (0, v3_1.headerValue)(value.headers, v3_1.SESSION_CLOSED_REASON_HEADER), "stream");
1494
+ internalAbort.abort();
1495
+ try {
1496
+ controller.close();
1497
+ }
1498
+ catch {
1499
+ /* already closed */
1500
+ }
1501
+ return;
1502
+ }
1503
+ if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION) {
1504
+ this.emitEvent({
1505
+ type: "run-pending-version",
1506
+ chatId,
1507
+ timestamp: Date.now(),
1508
+ source: "upgrade",
1509
+ });
1510
+ continue;
1511
+ }
1262
1512
  if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1263
1513
  // Skip a turn-complete from an earlier turn (committed `.in` cursor
1264
1514
  // below this send's seq), e.g. an undo action that raced this send.
@@ -1269,6 +1519,11 @@ class TriggerChatTransport {
1269
1519
  continue;
1270
1520
  }
1271
1521
  }
1522
+ // A close decided during this turn rides out on turn-complete —
1523
+ // the last record a reader sees before it ends the stream.
1524
+ if ((0, v3_1.headerValue)(value.headers, v3_1.SESSION_CLOSED_HEADER) === "true") {
1525
+ this.markSessionClosed(chatId, state, (0, v3_1.headerValue)(value.headers, v3_1.SESSION_CLOSED_REASON_HEADER), "stream");
1526
+ }
1272
1527
  const refreshedToken = (0, v3_1.headerValue)(value.headers, v3_1.PUBLIC_ACCESS_TOKEN_HEADER) ??
1273
1528
  legacyChunk?.publicAccessToken;
1274
1529
  if (refreshedToken) {