@trigger.dev/sdk 0.0.0-prerelease-20260911094758 → 0.0.0-prerelease-20260911153544

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.
@@ -149,6 +149,41 @@ export declare const ai: {
149
149
  * ```
150
150
  */
151
151
  declare function createChatAccessToken<TTask extends AnyTask>(taskId: TaskIdentifier<TTask>): Promise<string>;
152
+ /**
153
+ * Register work that must land before anything from this turn reaches the
154
+ * frontend.
155
+ *
156
+ * Like {@link chatDefer} the work starts immediately and is never awaited by
157
+ * the hook that registered it, so it runs alongside the model and costs no
158
+ * time to first token. Unlike `chat.defer`, the output stream waits for it:
159
+ * no chunk of the answer is written to the session until it settles. That
160
+ * makes it the right home for a write the next page load has to see (a
161
+ * conversation row, a message insert), because a reader that can see the
162
+ * answer can also see what the write persisted.
163
+ *
164
+ * Reach for `chat.defer` instead when the timing does not matter for a
165
+ * reload: analytics, audit logs, search-index updates.
166
+ *
167
+ * This is not a consistency barrier for the turn. The work is still in flight
168
+ * while the model runs, so a tool, a `prepareStep`, or anything else executing
169
+ * during the turn can still read the state as it was before the write. It
170
+ * orders the write against what the frontend can see, nothing more. When the
171
+ * turn's own code has to read the write back, `await` it instead and accept
172
+ * the cost.
173
+ *
174
+ * A write registered here that fails, or outlasts the internal timeout, lets
175
+ * the stream through rather than stalling the conversation.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * onTurnStart: async ({ chatId, uiMessages }) => {
180
+ * chat.deferBeforeOutput(
181
+ * db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
182
+ * );
183
+ * },
184
+ * ```
185
+ */
186
+ declare function chatDeferBeforeOutput(promiseOrFn: Promise<unknown> | (() => Promise<unknown>)): void;
152
187
  /**
153
188
  * A stream writer passed to chat lifecycle callbacks (`onPreload`, `onChatStart`,
154
189
  * `onTurnStart`, `onTurnComplete`, `onCompacted`).
@@ -3382,6 +3417,7 @@ export declare const chat: {
3382
3417
  cleanupAbortedParts: typeof cleanupAbortedParts;
3383
3418
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
3384
3419
  defer: typeof chatDefer;
3420
+ deferBeforeOutput: typeof chatDeferBeforeOutput;
3385
3421
  /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */
3386
3422
  inject: typeof injectBackgroundContext;
3387
3423
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */
@@ -683,31 +683,143 @@ exports.ai = {
683
683
  function createChatAccessToken(taskId) {
684
684
  return auth_js_1.auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
685
685
  }
686
- // ---------------------------------------------------------------------------
687
- // Chat transport helpers — backend side
688
- // ---------------------------------------------------------------------------
686
+ function createChatOutGate() {
687
+ let resolveOpened;
688
+ const opened = new Promise((resolve) => {
689
+ resolveOpened = resolve;
690
+ });
691
+ const gate = {
692
+ pending: new Set(),
693
+ open: false,
694
+ opened,
695
+ failOpen() {
696
+ if (gate.open)
697
+ return;
698
+ gate.open = true;
699
+ resolveOpened();
700
+ },
701
+ };
702
+ return gate;
703
+ }
704
+ const chatOutGateKey = locals_js_1.locals.create("chat.outGate");
689
705
  /**
690
- * Typed chat output stream — `.writer()`, `.pipe()`, `.append()`, and
691
- * `.read()` methods pre-bound to this run's Session `.out` channel and
692
- * typed to `UIMessageChunk`.
706
+ * How long a write waits on the gate before giving up. A storage that hangs
707
+ * degrades to an ungated write rather than stalling the conversation.
708
+ * @internal
709
+ */
710
+ const CHAT_OUT_GATE_TIMEOUT_MS = 10_000;
711
+ async function awaitChatOutGate() {
712
+ const gate = locals_js_1.locals.get(chatOutGateKey);
713
+ if (!gate || gate.open)
714
+ return;
715
+ const deadline = Date.now() + CHAT_OUT_GATE_TIMEOUT_MS;
716
+ while (gate.pending.size > 0) {
717
+ const remaining = deadline - Date.now();
718
+ if (remaining <= 0) {
719
+ gate.failOpen();
720
+ return;
721
+ }
722
+ const waitingOn = [...gate.pending];
723
+ let timedOut = false;
724
+ let timer;
725
+ try {
726
+ await Promise.race([
727
+ Promise.allSettled(waitingOn),
728
+ gate.opened,
729
+ new Promise((resolve) => {
730
+ timer = setTimeout(() => {
731
+ timedOut = true;
732
+ resolve();
733
+ }, remaining);
734
+ }),
735
+ ]);
736
+ }
737
+ finally {
738
+ if (timer)
739
+ clearTimeout(timer);
740
+ }
741
+ if (gate.open)
742
+ return;
743
+ if (timedOut) {
744
+ gate.failOpen();
745
+ return;
746
+ }
747
+ for (const settled of waitingOn)
748
+ gate.pending.delete(settled);
749
+ }
750
+ }
751
+ /**
752
+ * Register work that must land before anything from this turn reaches the
753
+ * frontend.
754
+ *
755
+ * Like {@link chatDefer} the work starts immediately and is never awaited by
756
+ * the hook that registered it, so it runs alongside the model and costs no
757
+ * time to first token. Unlike `chat.defer`, the output stream waits for it:
758
+ * no chunk of the answer is written to the session until it settles. That
759
+ * makes it the right home for a write the next page load has to see (a
760
+ * conversation row, a message insert), because a reader that can see the
761
+ * answer can also see what the write persisted.
762
+ *
763
+ * Reach for `chat.defer` instead when the timing does not matter for a
764
+ * reload: analytics, audit logs, search-index updates.
765
+ *
766
+ * This is not a consistency barrier for the turn. The work is still in flight
767
+ * while the model runs, so a tool, a `prepareStep`, or anything else executing
768
+ * during the turn can still read the state as it was before the write. It
769
+ * orders the write against what the frontend can see, nothing more. When the
770
+ * turn's own code has to read the write back, `await` it instead and accept
771
+ * the cost.
693
772
  *
694
- * Use from within a `chat.agent` run to write custom chunks:
773
+ * A write registered here that fails, or outlasts the internal timeout, lets
774
+ * the stream through rather than stalling the conversation.
775
+ *
776
+ * @example
695
777
  * ```ts
696
- * const { waitUntilComplete } = chat.stream.writer({
697
- * execute: ({ write }) => {
698
- * write({ type: "text-start", id: "status-1" });
699
- * write({ type: "text-delta", id: "status-1", delta: "Processing..." });
700
- * write({ type: "text-end", id: "status-1" });
701
- * },
702
- * });
703
- * await waitUntilComplete();
778
+ * onTurnStart: async ({ chatId, uiMessages }) => {
779
+ * chat.deferBeforeOutput(
780
+ * db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
781
+ * );
782
+ * },
704
783
  * ```
705
- *
706
- * Backed by the Session primitive so a chat's output outlives any single
707
- * run — subscribers (browser transport, server-side `ChatStream`) read
708
- * the session's `.out`, not a per-run stream. Run-scoped `target`
709
- * options on `.pipe()` are honoured as no-ops; the session is the target.
710
784
  */
785
+ function chatDeferBeforeOutput(promiseOrFn) {
786
+ const gate = locals_js_1.locals.get(chatOutGateKey);
787
+ const work = typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn;
788
+ if (!gate || gate.open)
789
+ return;
790
+ gate.pending.add(work);
791
+ }
792
+ function gateWriterOptions(options) {
793
+ return {
794
+ ...options,
795
+ execute: async (api) => {
796
+ await awaitChatOutGate();
797
+ return await options.execute(api);
798
+ },
799
+ };
800
+ }
801
+ function gateOutStream(value) {
802
+ return (async function* () {
803
+ await awaitChatOutGate();
804
+ if (isReadableStream(value)) {
805
+ const reader = value.getReader();
806
+ try {
807
+ while (true) {
808
+ const { done, value: chunk } = await reader.read();
809
+ if (done)
810
+ break;
811
+ yield chunk;
812
+ }
813
+ }
814
+ finally {
815
+ reader.releaseLock();
816
+ }
817
+ }
818
+ else {
819
+ yield* value;
820
+ }
821
+ })();
822
+ }
711
823
  const chatStream = {
712
824
  // Stable opaque label for the run-scoped `RealtimeDefinedStream` shape.
713
825
  // `chatStream` is backed by the Session's `.out` channel — this id is
@@ -717,7 +829,7 @@ const chatStream = {
717
829
  id: "chat",
718
830
  pipe(value, options) {
719
831
  const { target: _target, ...sessionOptions } = (options ?? {});
720
- return getChatSession().out.pipe(value, sessionOptions);
832
+ return getChatSession().out.pipe(gateOutStream(value), sessionOptions);
721
833
  },
722
834
  async read(_runId, options) {
723
835
  // Session channels don't need a runId — the session is the address.
@@ -727,10 +839,11 @@ const chatStream = {
727
839
  },
728
840
  async append(value, options) {
729
841
  const { target: _target, ...sessionOptions } = (options ?? {});
842
+ await awaitChatOutGate();
730
843
  return getChatSession().out.append(value, sessionOptions);
731
844
  },
732
845
  writer(options) {
733
- return getChatSession().out.writer(options);
846
+ return getChatSession().out.writer(gateWriterOptions(options));
734
847
  },
735
848
  };
736
849
  // ---------------------------------------------------------------------------
@@ -780,9 +893,13 @@ function createLazyChatWriter() {
780
893
  let mergeImpl = null;
781
894
  let waitPromise = null;
782
895
  let resolveExecute = null;
896
+ let started = false;
897
+ const bufferedParts = [];
898
+ const bufferedStreams = [];
783
899
  function ensureInitialized() {
784
- if (writeImpl)
900
+ if (started)
785
901
  return;
902
+ started = true;
786
903
  const executePromise = new Promise((resolve) => {
787
904
  resolveExecute = resolve;
788
905
  });
@@ -792,7 +909,11 @@ function createLazyChatWriter() {
792
909
  execute: ({ write, merge }) => {
793
910
  writeImpl = write;
794
911
  mergeImpl = merge;
795
- return executePromise; // Keep execute alive until flush()
912
+ for (const part of bufferedParts.splice(0))
913
+ write(part);
914
+ for (const stream of bufferedStreams.splice(0))
915
+ merge(stream);
916
+ return executePromise;
796
917
  },
797
918
  });
798
919
  waitPromise = waitUntilComplete;
@@ -802,11 +923,17 @@ function createLazyChatWriter() {
802
923
  write(part) {
803
924
  ensureInitialized();
804
925
  queueResponsePart(part);
805
- writeImpl(part);
926
+ if (writeImpl)
927
+ writeImpl(part);
928
+ else
929
+ bufferedParts.push(part);
806
930
  },
807
931
  merge(stream) {
808
932
  ensureInitialized();
809
- mergeImpl(stream);
933
+ if (mergeImpl)
934
+ mergeImpl(stream);
935
+ else
936
+ bufferedStreams.push(stream);
810
937
  },
811
938
  },
812
939
  async flush() {
@@ -3955,6 +4082,17 @@ function chatAgent(options) {
3955
4082
  * keeps an action's write cursor-neutral.
3956
4083
  */
3957
4084
  let lastSnapshotOutEventId;
4085
+ /**
4086
+ * The `lastInEventId` the most recent snapshot carried.
4087
+ *
4088
+ * A turn-start save happens after the incoming message has been handed to
4089
+ * the turn loop, so the router's live resume floor has already advanced
4090
+ * past it. Persisting that floor before the turn runs would let the next
4091
+ * boot resume past a message this run never answered, which is exactly
4092
+ * what a deferred or recovered message depends on. Turn-start carries
4093
+ * this instead.
4094
+ */
4095
+ let lastSnapshotInEventId;
3958
4096
  const storageTrigger = (trigger) => trigger === "regenerate-message"
3959
4097
  ? "regenerate-message"
3960
4098
  : trigger === "action" || trigger === "action-turn"
@@ -3968,7 +4106,8 @@ function chatAgent(options) {
3968
4106
  */
3969
4107
  /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
3970
4108
  let transcriptState = null;
3971
- const saveTranscript = async (opts) => {
4109
+ let transcriptSaveChain = Promise.resolve();
4110
+ const runSaveTranscript = async (opts) => {
3972
4111
  const { changes, shadow } = (0, transcriptStorage_js_1.diffTranscript)(transcriptShadow, opts.messages, {
3973
4112
  nonFinalIds: opts.nonFinalIds,
3974
4113
  });
@@ -3992,8 +4131,15 @@ function chatAgent(options) {
3992
4131
  if (runtimeState !== null || persistedStateSet) {
3993
4132
  changes.push({ op: "state", value: runtimeState });
3994
4133
  }
4134
+ if (opts.skipIfUnchanged && changes.length === 0)
4135
+ return;
3995
4136
  transcriptState = runtimeState;
3996
- const inCursor = chatInputRouter().resumeFloor();
4137
+ const liveInCursor = chatInputRouter().resumeFloor();
4138
+ const inCursor = opts.carryInCursor
4139
+ ? lastSnapshotInEventId
4140
+ : liveInCursor !== undefined
4141
+ ? String(liveInCursor)
4142
+ : undefined;
3997
4143
  await transcriptStorage.save({
3998
4144
  chatId: payload.chatId,
3999
4145
  clientData: opts.clientData,
@@ -4014,12 +4160,28 @@ function chatAgent(options) {
4014
4160
  },
4015
4161
  cursors: {
4016
4162
  lastOutEventId: opts.lastOutEventId,
4017
- lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
4163
+ lastInEventId: inCursor,
4018
4164
  },
4019
4165
  });
4020
4166
  transcriptShadow = shadow;
4167
+ lastSnapshotInEventId = inCursor;
4021
4168
  persistedStateSet = runtimeState !== null;
4022
4169
  };
4170
+ /**
4171
+ * Serialise every save onto one chain. `runSaveTranscript` derives its
4172
+ * changeset from `transcriptShadow` and only advances it once the write
4173
+ * lands, so two overlapping saves would diff against stale state. The
4174
+ * message list is copied on the way in because the accumulator keeps
4175
+ * mutating while a queued save waits its turn. A rejection is handed to
4176
+ * the caller but never poisons the chain.
4177
+ */
4178
+ const saveTranscript = (opts) => {
4179
+ const queued = { ...opts, messages: [...opts.messages] };
4180
+ const run = () => runSaveTranscript(queued);
4181
+ const next = transcriptSaveChain.then(run, run);
4182
+ transcriptSaveChain = next.then(() => undefined, () => undefined);
4183
+ return next;
4184
+ };
4023
4185
  /**
4024
4186
  * Persist the accumulator outside a turn.
4025
4187
  *
@@ -4126,6 +4288,7 @@ function chatAgent(options) {
4126
4288
  // turn (chain self-bootstraps from turn 2), so this is purely an
4127
4289
  // optimization to keep continuation runs bounded from the first turn.
4128
4290
  lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4291
+ lastSnapshotInEventId = bootSnapshot?.lastInEventId;
4129
4292
  if (bootSnapshot?.lastOutEventId !== undefined) {
4130
4293
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
4131
4294
  if (Number.isFinite(seeded)) {
@@ -4875,6 +5038,7 @@ function chatAgent(options) {
4875
5038
  // (errors are caught by the outer try/catch which writes an error chunk)
4876
5039
  locals_js_1.locals.set(chatPipeCountKey, 0);
4877
5040
  locals_js_1.locals.set(chatDeferKey, new Set());
5041
+ locals_js_1.locals.set(chatOutGateKey, createChatOutGate());
4878
5042
  locals_js_1.locals.set(chatCompactionStateKey, undefined);
4879
5043
  locals_js_1.locals.set(chatSteeringQueueKey, []);
4880
5044
  locals_js_1.locals.set(chatPendingBackgroundKey, []);
@@ -5363,6 +5527,23 @@ function chatAgent(options) {
5363
5527
  // A no-op turn skips this block, and with it `followSessionPin`:
5364
5528
  // there is nothing to answer, so nothing to hand over.
5365
5529
  if ((!isAction || actionTurn) && !isNoOpTurn) {
5530
+ if (!hydrateMessages) {
5531
+ chatDeferBeforeOutput(saveTranscript({
5532
+ reason: "turn-start",
5533
+ messages: accumulatedUIMessages,
5534
+ turn,
5535
+ trigger: storageTrigger(currentWirePayload.trigger),
5536
+ clientData,
5537
+ lastOutEventId: lastSnapshotOutEventId,
5538
+ skipIfUnchanged: true,
5539
+ carryInCursor: true,
5540
+ }).catch((error) => {
5541
+ v3_1.logger.warn("chat.agent: turn-start transcript write failed; a reload mid-answer may not show the message being answered", {
5542
+ error: error instanceof Error ? error.message : String(error),
5543
+ sessionId: sessionIdForSnapshot,
5544
+ });
5545
+ }));
5546
+ }
5366
5547
  // Mint a scoped public access token once per turn, reused for
5367
5548
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
5368
5549
  const currentRunId = ctx.run.id;
@@ -8839,6 +9020,7 @@ exports.chat = {
8839
9020
  cleanupAbortedParts,
8840
9021
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
8841
9022
  defer: chatDefer,
9023
+ deferBeforeOutput: chatDeferBeforeOutput,
8842
9024
  /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */
8843
9025
  inject: injectBackgroundContext,
8844
9026
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */