@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
@@ -22,12 +22,12 @@
22
22
  * }
23
23
  * ```
24
24
  */
25
- import { controlSubtype, headerValue, PUBLIC_ACCESS_TOKEN_HEADER, SESSION_IN_EVENT_ID_HEADER, SSEStreamSubscription, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
25
+ import { controlSubtype, headerValue, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, PUBLIC_ACCESS_TOKEN_HEADER, SESSION_IN_EVENT_ID_HEADER, SSEStreamSubscription, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
26
26
  function byteLength(body) {
27
27
  return new TextEncoder().encode(body).byteLength;
28
28
  }
29
29
  import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
30
- import { slimSubmitMessageForWire } from "./ai-shared.js";
30
+ import { MAX_EOF_RESUBSCRIBES, slimSubmitMessageForWire, waitBeforeEofResubscribe, } from "./ai-shared.js";
31
31
  const DEFAULT_BASE_URL = "https://api.trigger.dev";
32
32
  const DEFAULT_STREAM_TIMEOUT_SECONDS = 120;
33
33
  /**
@@ -40,6 +40,32 @@ function isAuthError(error) {
40
40
  const e = error;
41
41
  return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
42
42
  }
43
+ /**
44
+ * Detect the closed-session refusal from `.in/append`: HTTP 409 with the
45
+ * stable `session_closed` code. Terminal — never retried.
46
+ */
47
+ function isSessionClosedError(error) {
48
+ if (error === null || typeof error !== "object")
49
+ return false;
50
+ const e = error;
51
+ return e.name === "TriggerApiError" && e.status === 409 && e.code === "session_closed";
52
+ }
53
+ function sessionClosedReasonOf(error) {
54
+ if (error === null || typeof error !== "object")
55
+ return undefined;
56
+ const reason = error.closedReason;
57
+ return typeof reason === "string" ? reason : undefined;
58
+ }
59
+ /** The local form of the refusal, for sends the transport rejects itself. */
60
+ function sessionClosedError(chatId, reason) {
61
+ const err = new Error(reason ? `Chat session ${chatId} is closed: ${reason}` : `Chat session ${chatId} is closed`);
62
+ err.name = "TriggerApiError";
63
+ err.status = 409;
64
+ err.code = "session_closed";
65
+ if (reason)
66
+ err.closedReason = reason;
67
+ return err;
68
+ }
43
69
  /**
44
70
  * Detect a 404 from a session-PAT-authed call — i.e. the session doesn't
45
71
  * exist in the current environment. Happens when hydrated session state
@@ -145,6 +171,7 @@ export class TriggerChatTransport {
145
171
  _onSessionChange;
146
172
  _onEvent;
147
173
  sessions = new Map();
174
+ pendingResumeCursors = new Map();
148
175
  activeStreams = new Map();
149
176
  pendingStarts = new Map();
150
177
  // Last turn-producing send per chat — attribution source for the
@@ -184,6 +211,8 @@ export class TriggerChatTransport {
184
211
  lastEventId: session.lastEventId,
185
212
  activeInputSeq: session.activeInputSeq,
186
213
  isStreaming: session.isStreaming,
214
+ closed: session.closed,
215
+ closedReason: session.closedReason,
187
216
  });
188
217
  }
189
218
  }
@@ -233,6 +262,10 @@ export class TriggerChatTransport {
233
262
  */
234
263
  sendMessages = async (options) => {
235
264
  const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options;
265
+ const closedState = this.sessions.get(chatId);
266
+ if (closedState?.closed) {
267
+ throw sessionClosedError(chatId, closedState.closedReason);
268
+ }
236
269
  if (this.coordinator) {
237
270
  if (this.coordinator.isReadOnly(chatId)) {
238
271
  throw new Error("This chat is active in another tab");
@@ -242,6 +275,14 @@ export class TriggerChatTransport {
242
275
  const mergedMetadata = this.defaultMetadata || metadata
243
276
  ? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) }
244
277
  : undefined;
278
+ // An action sent through `useChat`. `useChatActions` (or any caller) puts
279
+ // it in `body.action`; sending it here rather than through
280
+ // `transport.sendAction` means `useChat` owns the response stream, so an
281
+ // action that becomes a turn renders the way a message turn does.
282
+ const actionInBody = body?.action;
283
+ if (actionInBody !== undefined) {
284
+ return this.sendAction(chatId, actionInBody, { abortSignal, metadata: mergedMetadata });
285
+ }
245
286
  // First-turn handover routing — when `headStart` is set AND no
246
287
  // session state exists yet for this chatId, POST the wire payload
247
288
  // to the customer's `chat.handover` route handler. The handler
@@ -304,11 +345,9 @@ export class TriggerChatTransport {
304
345
  activeStream.abort();
305
346
  this.activeStreams.delete(chatId);
306
347
  }
307
- // A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
308
- // turn would be skipped record by record.
309
- state.skipToTurnComplete = false;
310
348
  state.activeInputSeq = inSeq;
311
349
  state.isStreaming = true;
350
+ state.outstandingTurnAbandoned = false;
312
351
  this.notifySessionChange(chatId, state);
313
352
  // Owning turn: aborting this live send stops the turn the user drives.
314
353
  return this.subscribeToSessionStream(state, abortSignal, chatId, {
@@ -380,6 +419,16 @@ export class TriggerChatTransport {
380
419
  };
381
420
  this.sessions.set(chatId, state);
382
421
  this.notifySessionChange(chatId, state);
422
+ // Step 1 streams from the warm server either way; this says the agent run that owes step 2
423
+ // is parked on an undeployed external deployment id.
424
+ if (response.headers.get("X-Trigger-Chat-Pending-Version") === "1") {
425
+ this.emitEvent({
426
+ type: "run-pending-version",
427
+ chatId,
428
+ timestamp: Date.now(),
429
+ source: "head-start",
430
+ });
431
+ }
383
432
  // Filter the parsed UIMessage stream:
384
433
  // - Drop control chunks (`trigger:turn-complete`,
385
434
  // `trigger:session-state`) before they reach AI SDK — they
@@ -546,6 +595,9 @@ export class TriggerChatTransport {
546
595
  const state = this.sessions.get(options.chatId);
547
596
  if (!state)
548
597
  return null;
598
+ // A closed session has no further turns to resume.
599
+ if (state.closed)
600
+ return null;
549
601
  // Watch is a standing subscription: a settled session is exactly the
550
602
  // state it waits in, so a completed last turn must not block the resume.
551
603
  if (state.isStreaming === false && !this.watchMode)
@@ -590,7 +642,13 @@ export class TriggerChatTransport {
590
642
  catch {
591
643
  return false;
592
644
  }
593
- state.skipToTurnComplete = true;
645
+ // Only gate when a sent turn is still outstanding. A stop at a boundary has
646
+ // nothing to supersede, and gating it would swallow the next turn.
647
+ if (!state.outstandingTurnAbandoned &&
648
+ (state.isStreaming || state.activeInputSeq !== undefined)) {
649
+ state.skipToTurnComplete = true;
650
+ state.supersededInputSeq = state.activeInputSeq;
651
+ }
594
652
  const activeStream = this.activeStreams.get(chatId);
595
653
  if (activeStream) {
596
654
  activeStream.abort();
@@ -609,15 +667,28 @@ export class TriggerChatTransport {
609
667
  this.notifySessionChange(chatId, state);
610
668
  return true;
611
669
  };
670
+ /**
671
+ * Clear the supersede gate armed by a stop. Call this when the stopped
672
+ * turn died without ever writing its `trigger:turn-complete` boundary,
673
+ * otherwise the next turn stays gated forever.
674
+ */
675
+ clearSupersedeGate = (chatId) => {
676
+ const state = this.sessions.get(chatId);
677
+ if (!state)
678
+ return;
679
+ state.skipToTurnComplete = false;
680
+ state.supersededInputSeq = undefined;
681
+ state.outstandingTurnAbandoned = true;
682
+ };
612
683
  /**
613
684
  * Send a custom action chunk (for `chat.agent`'s `actionSchema` /
614
- * `onAction` hook). Actions are not turns only `hydrateMessages`
615
- * and `onAction` fire on the agent side. The returned stream
616
- * carries any model response `onAction` produced (when it returns a
617
- * `StreamTextResult`); for `void`-returning side-effect-only actions
618
- * the stream completes immediately with `trigger:turn-complete`.
685
+ * `onAction` hook). An action is an edit: only `hydrateMessages` and
686
+ * `onAction` fire on the agent side, and the returned stream completes
687
+ * with `trigger:turn-complete` once the edit is persisted. When `onAction`
688
+ * returns `chat.turn()` the turn's response follows on the same stream.
689
+ * Per-action `metadata` is merged over the transport's `clientData`.
619
690
  */
620
- sendAction = async (chatId, action) => {
691
+ sendAction = async (chatId, action, options) => {
621
692
  if (this.coordinator) {
622
693
  if (this.coordinator.isReadOnly(chatId)) {
623
694
  throw new Error("This chat is active in another tab");
@@ -629,7 +700,9 @@ export class TriggerChatTransport {
629
700
  chatId,
630
701
  trigger: "action",
631
702
  action,
632
- metadata: this.defaultMetadata ?? undefined,
703
+ metadata: this.defaultMetadata || options?.metadata
704
+ ? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
705
+ : undefined,
633
706
  };
634
707
  const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });
635
708
  const partId = crypto.randomUUID();
@@ -644,16 +717,14 @@ export class TriggerChatTransport {
644
717
  activeStream.abort();
645
718
  this.activeStreams.delete(chatId);
646
719
  }
647
- // A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
648
- // turn would be skipped record by record.
649
- state.skipToTurnComplete = false;
650
720
  // Mark streaming + persist so a reload mid-action resumes (reconnectToStream
651
721
  // no-ops when the persisted session says isStreaming: false).
652
722
  state.activeInputSeq = inSeq;
653
723
  state.isStreaming = true;
724
+ state.outstandingTurnAbandoned = false;
654
725
  this.notifySessionChange(chatId, state);
655
726
  // Owning action: aborting this send stops the turn the user drives.
656
- return this.subscribeToSessionStream(state, undefined, chatId, {
727
+ return this.subscribeToSessionStream(state, options?.abortSignal, chatId, {
657
728
  sinceInSeq: inSeq,
658
729
  sendStopOnAbort: true,
659
730
  });
@@ -668,14 +739,42 @@ export class TriggerChatTransport {
668
739
  return this.toPersisted(state);
669
740
  };
670
741
  setSession(chatId, session) {
671
- this.sessions.set(chatId, {
742
+ this.sessions.set(chatId, this.applyPendingResumeCursor(chatId, {
672
743
  publicAccessToken: session.publicAccessToken,
673
744
  lastEventId: session.lastEventId,
674
745
  activeInputSeq: session.activeInputSeq,
675
746
  isStreaming: session.isStreaming,
676
- });
747
+ }));
677
748
  this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)));
678
749
  }
750
+ /**
751
+ * Seed the `.out` resume cursor from a loaded transcript. Applied to the
752
+ * session now if it exists, otherwise held until the session is created so
753
+ * the first live subscription opens past the persisted history instead of
754
+ * replaying it. Never moves an existing cursor backward: the transcript load
755
+ * is async, so a live `.out` record can already have advanced the session
756
+ * past the snapshot, and overwriting it would replay those records.
757
+ */
758
+ seedResumeCursor = (chatId, lastEventId) => {
759
+ const existing = this.sessions.get(chatId);
760
+ if (existing?.publicAccessToken) {
761
+ if (existing.lastEventId === undefined) {
762
+ existing.lastEventId = lastEventId;
763
+ this.notifySessionChange(chatId, this.toPersisted(existing));
764
+ }
765
+ this.pendingResumeCursors.delete(chatId);
766
+ return;
767
+ }
768
+ this.pendingResumeCursors.set(chatId, lastEventId);
769
+ };
770
+ applyPendingResumeCursor(chatId, state) {
771
+ const pending = this.pendingResumeCursors.get(chatId);
772
+ if (pending !== undefined && state.lastEventId === undefined) {
773
+ state.lastEventId = pending;
774
+ }
775
+ this.pendingResumeCursors.delete(chatId);
776
+ return state;
777
+ }
679
778
  setOnSessionChange(callback) {
680
779
  this._onSessionChange = callback;
681
780
  }
@@ -746,6 +845,22 @@ export class TriggerChatTransport {
746
845
  setClientData(clientData) {
747
846
  this.defaultMetadata = clientData;
748
847
  }
848
+ /**
849
+ * Update the request-time callbacks. Same reason as `setClientData`:
850
+ * a host that rebuilds these per render (a dashboard navigating between
851
+ * projects) would otherwise keep hitting the endpoint captured at
852
+ * construction. Every read goes through the live field, so the next
853
+ * request uses the latest callback.
854
+ */
855
+ setAccessToken(accessToken) {
856
+ this.resolveAccessToken = accessToken;
857
+ }
858
+ setStartSession(startSession) {
859
+ this.resolveStartSession = startSession;
860
+ }
861
+ setFetch(fetchOverride) {
862
+ this.fetchOverride = fetchOverride;
863
+ }
749
864
  // -------------------------------------------------------------------------
750
865
  // Multi-tab coordination passthrough
751
866
  // -------------------------------------------------------------------------
@@ -793,7 +908,42 @@ export class TriggerChatTransport {
793
908
  lastEventId: state.lastEventId,
794
909
  activeInputSeq: state.activeInputSeq,
795
910
  isStreaming: state.isStreaming,
911
+ closed: state.closed,
912
+ closedReason: state.closedReason,
796
913
  });
914
+ /**
915
+ * Flip a session to closed and tell listeners once. Terminal: sends and
916
+ * reconnects stop from here on, and there is no reopening.
917
+ */
918
+ markSessionClosed(chatId, state, reason, source) {
919
+ if (state.closed)
920
+ return;
921
+ state.closed = true;
922
+ if (reason)
923
+ state.closedReason = reason;
924
+ state.isStreaming = false;
925
+ state.activeInputSeq = undefined;
926
+ state.skipToTurnComplete = false;
927
+ state.supersededInputSeq = undefined;
928
+ this.emitEvent({
929
+ type: "session-closed",
930
+ chatId,
931
+ timestamp: Date.now(),
932
+ ...(reason ? { reason } : {}),
933
+ source,
934
+ });
935
+ this.notifySessionChange(chatId, state);
936
+ this.coordinator?.release(chatId);
937
+ }
938
+ /**
939
+ * Whether the conversation has been closed — by the agent via
940
+ * `chat.close()`, or from outside via `sessions.close()`. Closed is
941
+ * one-way: the transport refuses further sends and reconnects, and the
942
+ * transcript stays readable.
943
+ */
944
+ sessionStatus = (chatId) => this.sessions.get(chatId)?.closed ? "closed" : "open";
945
+ /** The reason the session was closed, when one was given. */
946
+ sessionClosedReason = (chatId) => this.sessions.get(chatId)?.closedReason;
797
947
  notifySessionChange(chatId, session) {
798
948
  if (!this._onSessionChange)
799
949
  return;
@@ -828,7 +978,7 @@ export class TriggerChatTransport {
828
978
  // `sessions: { ... }` already, or the very first `accessToken` call
829
979
  // returns a PAT for an out-of-band-created session.
830
980
  const token = await this.resolveAccessToken({ chatId });
831
- const state = { publicAccessToken: token };
981
+ const state = this.applyPendingResumeCursor(chatId, { publicAccessToken: token });
832
982
  this.sessions.set(chatId, state);
833
983
  this.notifySessionChange(chatId, state);
834
984
  return state;
@@ -837,15 +987,23 @@ export class TriggerChatTransport {
837
987
  if (!this.resolveStartSession) {
838
988
  throw new Error("TriggerChatTransport: `startSession` is required to call `start()` / `preload()`. Either provide it or pre-hydrate the session via `sessions: { ... }`.");
839
989
  }
840
- const { publicAccessToken } = await this.resolveStartSession({
990
+ const { publicAccessToken, pendingVersion } = await this.resolveStartSession({
841
991
  taskId: this.taskId,
842
992
  chatId,
843
993
  clientData: (this.defaultMetadata ?? {}),
844
994
  });
845
- const state = {
995
+ if (pendingVersion) {
996
+ this.emitEvent({
997
+ type: "run-pending-version",
998
+ chatId,
999
+ timestamp: Date.now(),
1000
+ source: "start",
1001
+ });
1002
+ }
1003
+ const state = this.applyPendingResumeCursor(chatId, {
846
1004
  publicAccessToken,
847
1005
  isStreaming: false,
848
- };
1006
+ });
849
1007
  this.sessions.set(chatId, state);
850
1008
  this.notifySessionChange(chatId, state);
851
1009
  return state;
@@ -858,6 +1016,33 @@ export class TriggerChatTransport {
858
1016
  const raw = this.resolveBaseURLFn(ctx);
859
1017
  return raw.replace(/\/$/, "");
860
1018
  }
1019
+ /**
1020
+ * `fetchClient` for an `out` subscription. Reads `this.fetchOverride` on
1021
+ * every call, not once per subscription: a watch-mode subscription
1022
+ * reconnects for the lifetime of the page, and `setFetch` may have swapped
1023
+ * the override in between.
1024
+ */
1025
+ sseFetchClient(ctx) {
1026
+ return ((input, init) => {
1027
+ const override = this.fetchOverride;
1028
+ if (!override)
1029
+ return fetch(input, init);
1030
+ if (typeof input === "string") {
1031
+ return override(input, init ?? {}, ctx);
1032
+ }
1033
+ if (input instanceof URL) {
1034
+ return override(input.toString(), init ?? {}, ctx);
1035
+ }
1036
+ // Request — preserve its url + intrinsic init, let any provided init
1037
+ // override on top (matches fetch(Request, init) semantics).
1038
+ return override(input.url, {
1039
+ method: input.method,
1040
+ headers: input.headers,
1041
+ signal: input.signal,
1042
+ ...(init ?? {}),
1043
+ }, ctx);
1044
+ });
1045
+ }
861
1046
  async doFetch(ctx, url, init) {
862
1047
  return this.fetchOverride ? this.fetchOverride(url, init, ctx) : fetch(url, init);
863
1048
  }
@@ -879,24 +1064,65 @@ export class TriggerChatTransport {
879
1064
  const err = new Error(`appendToSessionStream failed: ${response.status} ${text}`);
880
1065
  err.name = "TriggerApiError";
881
1066
  err.status = response.status;
1067
+ let parsed;
1068
+ try {
1069
+ parsed = JSON.parse(text);
1070
+ }
1071
+ catch {
1072
+ parsed = undefined;
1073
+ }
1074
+ if (typeof parsed?.code === "string")
1075
+ err.code = parsed.code;
1076
+ if (typeof parsed?.closedReason === "string")
1077
+ err.closedReason = parsed.closedReason;
882
1078
  throw err;
883
1079
  }
884
1080
  // The appended record's `.in` seq, for correlating the response stream to
885
1081
  // this send. Omitted by older webapps / a lost idempotency claim.
886
1082
  const data = (await response.json().catch(() => undefined));
1083
+ if (data?.pendingVersion === true) {
1084
+ this.emitEvent({
1085
+ type: "run-pending-version",
1086
+ chatId,
1087
+ timestamp: Date.now(),
1088
+ source: "send",
1089
+ });
1090
+ }
887
1091
  return typeof data?.seq === "number" ? data.seq : undefined;
888
1092
  }
889
1093
  async callWithAuthRetry(chatId, state, op) {
1094
+ if (state.closed) {
1095
+ throw sessionClosedError(chatId, state.closedReason);
1096
+ }
1097
+ // Every attempt goes through here, so a closed-session refusal is terminal
1098
+ // on whichever one hits it. Reaching a 409 from a later attempt and not
1099
+ // marking it would leave local status open on a conversation the server
1100
+ // has already ended.
1101
+ const attempt = async (token) => {
1102
+ try {
1103
+ return await op(token);
1104
+ }
1105
+ catch (err) {
1106
+ if (isSessionClosedError(err)) {
1107
+ // Terminal. Retrying, refreshing the PAT, or recreating the session
1108
+ // would all be wrong: the conversation is over.
1109
+ this.markSessionClosed(chatId, state, sessionClosedReasonOf(err), "append");
1110
+ }
1111
+ throw err;
1112
+ }
1113
+ };
890
1114
  // 1) Try with the current PAT.
891
1115
  try {
892
- return await op(state.publicAccessToken);
1116
+ return await attempt(state.publicAccessToken);
893
1117
  }
894
1118
  catch (err) {
1119
+ if (isSessionClosedError(err))
1120
+ throw err;
895
1121
  if (isSessionNotFoundError(err)) {
896
1122
  // The cached PAT authenticated but the session doesn't exist here —
897
1123
  // recreate it and retry.
898
1124
  await this.recreateSession(chatId, state);
899
- return await op(state.publicAccessToken);
1125
+ return await attempt(state.publicAccessToken);
900
1126
  }
901
1127
  if (!isAuthError(err))
902
1128
  throw err;
@@ -907,7 +1133,7 @@ export class TriggerChatTransport {
907
1133
  state.publicAccessToken = fresh;
908
1134
  this.notifySessionChange(chatId, state);
909
1135
  try {
910
- return await op(fresh);
1136
+ return await attempt(fresh);
911
1137
  }
912
1138
  catch (err) {
913
1139
  if (!isSessionNotFoundError(err))
@@ -917,7 +1143,7 @@ export class TriggerChatTransport {
917
1143
  // state is stale (created in a different environment, or before the
918
1144
  // sessions upgrade). Recreate the session and retry once.
919
1145
  await this.recreateSession(chatId, state);
920
- return await op(state.publicAccessToken);
1146
+ return await attempt(state.publicAccessToken);
921
1147
  }
922
1148
  /**
923
1149
  * Repair a stale/phantom hydrated session. The cached state pointed at a
@@ -933,11 +1159,19 @@ export class TriggerChatTransport {
933
1159
  if (!this.resolveStartSession) {
934
1160
  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.");
935
1161
  }
936
- const { publicAccessToken } = await this.resolveStartSession({
1162
+ const { publicAccessToken, pendingVersion } = await this.resolveStartSession({
937
1163
  taskId: this.taskId,
938
1164
  chatId,
939
1165
  clientData: (this.defaultMetadata ?? {}),
940
1166
  });
1167
+ if (pendingVersion) {
1168
+ this.emitEvent({
1169
+ type: "run-pending-version",
1170
+ chatId,
1171
+ timestamp: Date.now(),
1172
+ source: "start",
1173
+ });
1174
+ }
941
1175
  state.publicAccessToken = publicAccessToken;
942
1176
  state.lastEventId = undefined;
943
1177
  state.isStreaming = false;
@@ -961,8 +1195,13 @@ export class TriggerChatTransport {
961
1195
  : internalAbort.signal;
962
1196
  if (abortSignal) {
963
1197
  abortSignal.addEventListener("abort", () => {
964
- if (options?.sendStopOnAbort !== false) {
1198
+ // A late abort (unmount, or the consumer dropping a drained stream)
1199
+ // has no turn to stop: don't gate the next one, don't write a stop.
1200
+ const outstanding = !state.outstandingTurnAbandoned &&
1201
+ (state.isStreaming || state.activeInputSeq !== undefined);
1202
+ if (options?.sendStopOnAbort !== false && outstanding && !internalAbort.signal.aborted) {
965
1203
  state.skipToTurnComplete = true;
1204
+ state.supersededInputSeq = state.activeInputSeq;
966
1205
  this.appendInputChunk(chatId, state.publicAccessToken, this.serializeInputChunk({ kind: "stop" })).catch(() => { });
967
1206
  }
968
1207
  internalAbort.abort();
@@ -1022,26 +1261,7 @@ export class TriggerChatTransport {
1022
1261
  })()
1023
1262
  : () => { };
1024
1263
  const sseCtx = { endpoint: "out", chatId };
1025
- const fetchOverride = this.fetchOverride;
1026
- const sseFetchClient = fetchOverride
1027
- ? ((input, init) => {
1028
- if (typeof input === "string") {
1029
- return fetchOverride(input, init ?? {}, sseCtx);
1030
- }
1031
- if (input instanceof URL) {
1032
- return fetchOverride(input.toString(), init ?? {}, sseCtx);
1033
- }
1034
- // Request — preserve its url + intrinsic init, let any
1035
- // provided init override on top (matches fetch(Request, init)
1036
- // semantics).
1037
- return fetchOverride(input.url, {
1038
- method: input.method,
1039
- headers: input.headers,
1040
- signal: input.signal,
1041
- ...(init ?? {}),
1042
- }, sseCtx);
1043
- })
1044
- : undefined;
1264
+ const sseFetchClient = this.sseFetchClient(sseCtx);
1045
1265
  let sawFirstChunk = false;
1046
1266
  let sinceInSeq = options?.sinceInSeq;
1047
1267
  const connectSseOnce = async (token) => {
@@ -1089,12 +1309,6 @@ export class TriggerChatTransport {
1089
1309
  return await connectSseOnce(fresh);
1090
1310
  }
1091
1311
  };
1092
- // A body that ends without a turn-complete is only terminal when the
1093
- // server says the session settled — otherwise the turn is still
1094
- // running and we lost the connection (long-poll window closed, proxy
1095
- // restarted). Resubscribe from `state.lastEventId`, bounded so a
1096
- // permanently empty stream can't spin.
1097
- const MAX_EOF_RESUBSCRIBES = 5;
1098
1312
  let eofResubscribes = 0;
1099
1313
  const resumeAfterEof = async () => {
1100
1314
  // Watch mode is a standing subscription: it outlives turn-complete
@@ -1104,21 +1318,7 @@ export class TriggerChatTransport {
1104
1318
  !currentSubscription?.sessionSettled &&
1105
1319
  !combinedSignal.aborted) {
1106
1320
  eofResubscribes++;
1107
- // Sleep, but wake immediately on abort — otherwise a stop lands
1108
- // mid-backoff and the stream stays open for the rest of it.
1109
- await new Promise((resolve) => {
1110
- let timer;
1111
- const done = () => {
1112
- clearTimeout(timer);
1113
- combinedSignal.removeEventListener("abort", done);
1114
- resolve();
1115
- };
1116
- // Jitter the backoff so many clients reconnecting after the same
1117
- // dropped window don't resubscribe in lockstep.
1118
- const backoff = Math.min(100 * 2 ** (eofResubscribes - 1), 5_000);
1119
- timer = setTimeout(done, backoff * (0.5 + Math.random() * 0.5));
1120
- combinedSignal.addEventListener("abort", done);
1121
- });
1321
+ await waitBeforeEofResubscribe(eofResubscribes, combinedSignal);
1122
1322
  if (combinedSignal.aborted)
1123
1323
  break;
1124
1324
  const opened = await openWithAuthRetry();
@@ -1242,11 +1442,41 @@ export class TriggerChatTransport {
1242
1442
  continue;
1243
1443
  }
1244
1444
  }
1245
- if (state.skipToTurnComplete) {
1246
- if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1247
- state.skipToTurnComplete = false;
1445
+ if (state.skipToTurnComplete &&
1446
+ controlValue !== TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED &&
1447
+ controlValue !== TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION) {
1448
+ // Fall through on a boundary so the `sinceInSeq` check and the
1449
+ // turn-completed emit below still run; content records are skipped.
1450
+ // A close is terminal and a pending version is the gated turn's own
1451
+ // handover status, so neither is ever gated away.
1452
+ if (controlValue !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
1453
+ continue;
1454
+ // A boundary older than the superseded turn is not its boundary —
1455
+ // keep gating. A header-less boundary (legacy wire) still clears.
1456
+ const gateCursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
1457
+ const gateCursor = gateCursorRaw !== undefined ? Number.parseInt(gateCursorRaw, 10) : NaN;
1458
+ if (state.supersededInputSeq !== undefined &&
1459
+ !Number.isNaN(gateCursor) &&
1460
+ gateCursor < state.supersededInputSeq) {
1461
+ continue;
1462
+ }
1463
+ state.skipToTurnComplete = false;
1464
+ state.supersededInputSeq = undefined;
1465
+ // This boundary is the new turn's own, so the gate swallowed its
1466
+ // output: fail the turn instead of completing an empty answer, and
1467
+ // leave nothing armed for the retry.
1468
+ if (sinceInSeq !== undefined &&
1469
+ !Number.isNaN(gateCursor) &&
1470
+ gateCursor >= sinceInSeq) {
1471
+ // The lost boundary may also carry the session close.
1472
+ if (headerValue(value.headers, SESSION_CLOSED_HEADER) === "true") {
1473
+ this.markSessionClosed(chatId, state, headerValue(value.headers, SESSION_CLOSED_REASON_HEADER), "stream");
1474
+ }
1475
+ state.activeInputSeq = undefined;
1476
+ state.isStreaming = false;
1477
+ this.notifySessionChange(chatId, state);
1478
+ throw new Error("The previous turn's output was lost. Try again.");
1248
1479
  }
1249
- continue;
1250
1480
  }
1251
1481
  if (controlValue === TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED) {
1252
1482
  // Server has already triggered the new run via
@@ -1255,6 +1485,26 @@ export class TriggerChatTransport {
1255
1485
  // and keep reading.
1256
1486
  continue;
1257
1487
  }
1488
+ if (controlValue === TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED) {
1489
+ this.markSessionClosed(chatId, state, headerValue(value.headers, SESSION_CLOSED_REASON_HEADER), "stream");
1490
+ internalAbort.abort();
1491
+ try {
1492
+ controller.close();
1493
+ }
1494
+ catch {
1495
+ /* already closed */
1496
+ }
1497
+ return;
1498
+ }
1499
+ if (controlValue === TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION) {
1500
+ this.emitEvent({
1501
+ type: "run-pending-version",
1502
+ chatId,
1503
+ timestamp: Date.now(),
1504
+ source: "upgrade",
1505
+ });
1506
+ continue;
1507
+ }
1258
1508
  if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1259
1509
  // Skip a turn-complete from an earlier turn (committed `.in` cursor
1260
1510
  // below this send's seq), e.g. an undo action that raced this send.
@@ -1265,6 +1515,11 @@ export class TriggerChatTransport {
1265
1515
  continue;
1266
1516
  }
1267
1517
  }
1518
+ // A close decided during this turn rides out on turn-complete —
1519
+ // the last record a reader sees before it ends the stream.
1520
+ if (headerValue(value.headers, SESSION_CLOSED_HEADER) === "true") {
1521
+ this.markSessionClosed(chatId, state, headerValue(value.headers, SESSION_CLOSED_REASON_HEADER), "stream");
1522
+ }
1268
1523
  const refreshedToken = headerValue(value.headers, PUBLIC_ACCESS_TOKEN_HEADER) ??
1269
1524
  legacyChunk?.publicAccessToken;
1270
1525
  if (refreshedToken) {