@trigger.dev/sdk 0.0.0-prerelease-20260909121655 → 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.
Files changed (40) hide show
  1. package/dist/commonjs/v3/ai.d.ts +36 -0
  2. package/dist/commonjs/v3/ai.js +260 -55
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/auth.d.ts +10 -4
  5. package/dist/commonjs/v3/auth.js.map +1 -1
  6. package/dist/commonjs/v3/chatSnapshotIo.js +6 -7
  7. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -1
  8. package/dist/commonjs/v3/retry.d.ts +4 -0
  9. package/dist/commonjs/v3/retry.js +22 -13
  10. package/dist/commonjs/v3/retry.js.map +1 -1
  11. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -1
  12. package/dist/commonjs/v3/transcriptStorage.d.ts +43 -6
  13. package/dist/commonjs/v3/transcriptStorage.js +87 -8
  14. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  15. package/dist/commonjs/version.js +1 -1
  16. package/dist/esm/v3/ai.d.ts +36 -0
  17. package/dist/esm/v3/ai.js +260 -55
  18. package/dist/esm/v3/ai.js.map +1 -1
  19. package/dist/esm/v3/auth.d.ts +10 -4
  20. package/dist/esm/v3/auth.js.map +1 -1
  21. package/dist/esm/v3/chatSnapshotIo.js +7 -8
  22. package/dist/esm/v3/chatSnapshotIo.js.map +1 -1
  23. package/dist/esm/v3/retry.d.ts +4 -0
  24. package/dist/esm/v3/retry.js +14 -8
  25. package/dist/esm/v3/retry.js.map +1 -1
  26. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -1
  27. package/dist/esm/v3/transcriptStorage.d.ts +43 -6
  28. package/dist/esm/v3/transcriptStorage.js +85 -8
  29. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  30. package/dist/esm/version.js +1 -1
  31. package/docs/ai-chat/background-injection.mdx +54 -1
  32. package/docs/ai-chat/how-it-works.mdx +1 -1
  33. package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
  34. package/docs/ai-chat/patterns/database-persistence.mdx +18 -7
  35. package/docs/ai-chat/reference.mdx +2 -1
  36. package/docs/ai-chat/side-channels.mdx +1 -1
  37. package/docs/ai-chat/transcript-storage.mdx +21 -7
  38. package/docs/self-hosting/security.mdx +12 -0
  39. package/package.json +2 -2
  40. package/skills/trigger-chat-agent-advanced/SKILL.md +31 -15
@@ -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");
705
+ /**
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
+ }
689
751
  /**
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`.
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.
693
765
  *
694
- * Use from within a `chat.agent` run to write custom chunks:
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.
772
+ *
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() {
@@ -1615,15 +1742,11 @@ async function installChatInputRouter(chatId, options) {
1615
1742
  checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
1616
1743
  }
1617
1744
  }
1618
- // A boot that replayed `.in` itself has already answered everything up to
1619
- // `recoveredThrough`, so the floor has to cover it before the tail opens.
1620
- if (options?.recoveredThrough !== undefined) {
1621
- const recovered = options.recoveredThrough;
1622
- checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
1623
- checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? checkpoint.resumeFrom, checkpoint.resumeFrom);
1624
- }
1625
1745
  const router = entry.router;
1626
1746
  router.restore(checkpoint);
1747
+ if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) {
1748
+ router.markRecovered(options.recoveredSeqNums);
1749
+ }
1627
1750
  const floor = router.resumeFrom();
1628
1751
  if (floor !== undefined) {
1629
1752
  v3_1.sessionStreams.setLastSeqNum(chatId, "in", floor);
@@ -3959,6 +4082,17 @@ function chatAgent(options) {
3959
4082
  * keeps an action's write cursor-neutral.
3960
4083
  */
3961
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;
3962
4096
  const storageTrigger = (trigger) => trigger === "regenerate-message"
3963
4097
  ? "regenerate-message"
3964
4098
  : trigger === "action" || trigger === "action-turn"
@@ -3972,7 +4106,8 @@ function chatAgent(options) {
3972
4106
  */
3973
4107
  /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
3974
4108
  let transcriptState = null;
3975
- const saveTranscript = async (opts) => {
4109
+ let transcriptSaveChain = Promise.resolve();
4110
+ const runSaveTranscript = async (opts) => {
3976
4111
  const { changes, shadow } = (0, transcriptStorage_js_1.diffTranscript)(transcriptShadow, opts.messages, {
3977
4112
  nonFinalIds: opts.nonFinalIds,
3978
4113
  });
@@ -3996,8 +4131,15 @@ function chatAgent(options) {
3996
4131
  if (runtimeState !== null || persistedStateSet) {
3997
4132
  changes.push({ op: "state", value: runtimeState });
3998
4133
  }
4134
+ if (opts.skipIfUnchanged && changes.length === 0)
4135
+ return;
3999
4136
  transcriptState = runtimeState;
4000
- const inCursor = chatInputRouter().resumeFloor();
4137
+ const liveInCursor = chatInputRouter().resumeFloor();
4138
+ const inCursor = opts.carryInCursor
4139
+ ? lastSnapshotInEventId
4140
+ : liveInCursor !== undefined
4141
+ ? String(liveInCursor)
4142
+ : undefined;
4001
4143
  await transcriptStorage.save({
4002
4144
  chatId: payload.chatId,
4003
4145
  clientData: opts.clientData,
@@ -4018,12 +4160,28 @@ function chatAgent(options) {
4018
4160
  },
4019
4161
  cursors: {
4020
4162
  lastOutEventId: opts.lastOutEventId,
4021
- lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
4163
+ lastInEventId: inCursor,
4022
4164
  },
4023
4165
  });
4024
4166
  transcriptShadow = shadow;
4167
+ lastSnapshotInEventId = inCursor;
4025
4168
  persistedStateSet = runtimeState !== null;
4026
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
+ };
4027
4185
  /**
4028
4186
  * Persist the accumulator outside a turn.
4029
4187
  *
@@ -4074,6 +4232,13 @@ function chatAgent(options) {
4074
4232
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
4075
4233
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
4076
4234
  const bootInjectedQueue = [];
4235
+ const recoveredSeqByPayload = new WeakMap();
4236
+ const dispatchBootInjected = () => bootInjectedQueue.shift();
4237
+ const settleRecoveredTurn = (wirePayload) => {
4238
+ const settledSeq = recoveredSeqByPayload.get(wirePayload);
4239
+ if (settledSeq !== undefined)
4240
+ chatInputRouter().settleRecovered(settledSeq);
4241
+ };
4077
4242
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
4078
4243
  // `.in` resume cursor, computed at most once per boot. The boot
4079
4244
  // block below resolves it (snapshot field or records scan) and the
@@ -4123,6 +4288,7 @@ function chatAgent(options) {
4123
4288
  // turn (chain self-bootstraps from turn 2), so this is purely an
4124
4289
  // optimization to keep continuation runs bounded from the first turn.
4125
4290
  lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
4291
+ lastSnapshotInEventId = bootSnapshot?.lastInEventId;
4126
4292
  if (bootSnapshot?.lastOutEventId !== undefined) {
4127
4293
  const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
4128
4294
  if (Number.isFinite(seeded)) {
@@ -4214,16 +4380,10 @@ function chatAgent(options) {
4214
4380
  }
4215
4381
  // ── session.in router ──────────────────────────────────────────
4216
4382
  //
4217
- // Reads the turn boundary and subscribes in one call. `bootInCursor` is
4218
- // only a fallback: the boot block above may already have resolved a
4219
- // cursor from the snapshot, which is used when the boundary itself
4220
- // carries none. Everything the boot replayed off `.in` is dispatched from
4221
- // `bootInjectedQueue` below, so it goes into the floor here — folded in
4222
- // after the subscription opens, the live tail re-delivers it as a turn.
4223
- const lastRecoveredInSeq = replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1].seqNum : undefined;
4383
+ const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
4224
4384
  await installChatInputRouter(payload.chatId, {
4225
4385
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4226
- recoveredThrough: lastRecoveredInSeq,
4386
+ recoveredSeqNums,
4227
4387
  resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
4228
4388
  });
4229
4389
  // ── Recovery boot + chain reconstruction ────────────────────────
@@ -4306,7 +4466,7 @@ function chatAgent(options) {
4306
4466
  // branches: at n=1 the orphan partial is dropped and the interrupted
4307
4467
  // user is re-dispatched as a fresh turn instead.
4308
4468
  let seedChain;
4309
- let recoveredTurns;
4469
+ let recoveredEntries;
4310
4470
  if (hookChain !== undefined) {
4311
4471
  seedChain = hookChain;
4312
4472
  }
@@ -4317,13 +4477,26 @@ function chatAgent(options) {
4317
4477
  seedChain = settledMessages;
4318
4478
  }
4319
4479
  if (hookRecoveredTurns !== undefined) {
4320
- recoveredTurns = hookRecoveredTurns;
4480
+ const seqNumsByRecoveredId = new Map();
4481
+ for (const entry of replayedInTail) {
4482
+ const existing = seqNumsByRecoveredId.get(entry.message.id);
4483
+ if (existing)
4484
+ existing.push(entry.seqNum);
4485
+ else
4486
+ seqNumsByRecoveredId.set(entry.message.id, [entry.seqNum]);
4487
+ }
4488
+ recoveredEntries = hookRecoveredTurns.map((message) => ({
4489
+ message,
4490
+ seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4491
+ }));
4321
4492
  }
4322
4493
  else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
4323
- recoveredTurns = inFlightUsers.slice(1);
4494
+ recoveredEntries = replayedInTail
4495
+ .slice(1)
4496
+ .map((r) => ({ message: r.message, seqNum: r.seqNum }));
4324
4497
  }
4325
4498
  else {
4326
- recoveredTurns = inFlightUsers;
4499
+ recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum }));
4327
4500
  }
4328
4501
  // `beforeBoot` errors bubble — the customer opted into blocking
4329
4502
  // persistence and a failure there should fail the run rather than
@@ -4353,13 +4526,14 @@ function chatAgent(options) {
4353
4526
  for (const entry of replayedInTail) {
4354
4527
  metadataById.set(entry.message.id, entry.metadata);
4355
4528
  }
4356
- for (const msg of recoveredTurns) {
4529
+ const dispatchedRecoveredSeqs = new Set();
4530
+ for (const { message: msg, seqNum } of recoveredEntries) {
4357
4531
  if (wireMessageId && msg.id === wireMessageId)
4358
4532
  continue;
4359
4533
  const recoveredMetadata = metadataById.has(msg.id)
4360
4534
  ? metadataById.get(msg.id)
4361
4535
  : payload.metadata;
4362
- bootInjectedQueue.push({
4536
+ const injectedPayload = {
4363
4537
  chatId: payload.chatId,
4364
4538
  sessionId: payload.sessionId,
4365
4539
  metadata: recoveredMetadata,
@@ -4368,7 +4542,17 @@ function chatAgent(options) {
4368
4542
  messageId: msg.id,
4369
4543
  continuation: payload.continuation,
4370
4544
  previousRunId: payload.previousRunId,
4371
- });
4545
+ };
4546
+ bootInjectedQueue.push(injectedPayload);
4547
+ if (seqNum !== undefined) {
4548
+ recoveredSeqByPayload.set(injectedPayload, seqNum);
4549
+ dispatchedRecoveredSeqs.add(seqNum);
4550
+ }
4551
+ }
4552
+ for (const entry of replayedInTail) {
4553
+ if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4554
+ chatInputRouter().settleRecovered(entry.seqNum);
4555
+ }
4372
4556
  }
4373
4557
  accumulatedUIMessages = seedChain;
4374
4558
  // ── Head-start bootstrap ─────────────────────────────────────
@@ -4528,7 +4712,7 @@ function chatAgent(options) {
4528
4712
  */
4529
4713
  let dispatchedRecoveredFirstTurn = false;
4530
4714
  if (preloaded && bootInjectedQueue.length > 0) {
4531
- currentWirePayload = bootInjectedQueue.shift();
4715
+ currentWirePayload = dispatchBootInjected();
4532
4716
  dispatchedRecoveredFirstTurn = true;
4533
4717
  }
4534
4718
  // Handle preloaded runs — fire onPreload, then wait for the first real message
@@ -4736,7 +4920,7 @@ function chatAgent(options) {
4736
4920
  // waiting on the live session.in. Subsequent recovered turns
4737
4921
  // get drained by the end-of-turn picker below.
4738
4922
  if (bootInjectedQueue.length > 0) {
4739
- currentWirePayload = bootInjectedQueue.shift();
4923
+ currentWirePayload = dispatchBootInjected();
4740
4924
  }
4741
4925
  else {
4742
4926
  const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds;
@@ -4854,6 +5038,7 @@ function chatAgent(options) {
4854
5038
  // (errors are caught by the outer try/catch which writes an error chunk)
4855
5039
  locals_js_1.locals.set(chatPipeCountKey, 0);
4856
5040
  locals_js_1.locals.set(chatDeferKey, new Set());
5041
+ locals_js_1.locals.set(chatOutGateKey, createChatOutGate());
4857
5042
  locals_js_1.locals.set(chatCompactionStateKey, undefined);
4858
5043
  locals_js_1.locals.set(chatSteeringQueueKey, []);
4859
5044
  locals_js_1.locals.set(chatPendingBackgroundKey, []);
@@ -5300,6 +5485,7 @@ function chatAgent(options) {
5300
5485
  chatId: currentWirePayload.chatId,
5301
5486
  messageId: currentWirePayload.messageId,
5302
5487
  });
5488
+ settleRecoveredTurn(currentWirePayload);
5303
5489
  await writeTurnCompleteChunk(currentWirePayload.chatId);
5304
5490
  // Not a turn — don't consume an iteration.
5305
5491
  turn--;
@@ -5341,6 +5527,23 @@ function chatAgent(options) {
5341
5527
  // A no-op turn skips this block, and with it `followSessionPin`:
5342
5528
  // there is nothing to answer, so nothing to hand over.
5343
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
+ }
5344
5547
  // Mint a scoped public access token once per turn, reused for
5345
5548
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
5346
5549
  const currentRunId = ctx.run.id;
@@ -6015,6 +6218,7 @@ function chatAgent(options) {
6015
6218
  }
6016
6219
  locals_js_1.locals.set(chatResponsePartsKey, []);
6017
6220
  }
6221
+ settleRecoveredTurn(currentWirePayload);
6018
6222
  // Write turn-complete control chunk — closes the frontend stream.
6019
6223
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
6020
6224
  // Fire onTurnComplete — stream is closed, use for persistence.
@@ -6126,7 +6330,7 @@ function chatAgent(options) {
6126
6330
  // produced these from in-flight user messages on session.in
6127
6331
  // that the dead predecessor never acknowledged.
6128
6332
  if (bootInjectedQueue.length > 0) {
6129
- currentWirePayload = bootInjectedQueue.shift();
6333
+ currentWirePayload = dispatchBootInjected();
6130
6334
  return "continue";
6131
6335
  }
6132
6336
  // chat.requestUpgrade() was called — exit the loop; the handover
@@ -6446,7 +6650,7 @@ function chatAgent(options) {
6446
6650
  // recovered turn shouldn't strand the rest of the boot queue
6447
6651
  // until an unrelated live message arrives.
6448
6652
  if (bootInjectedQueue.length > 0) {
6449
- currentWirePayload = bootInjectedQueue.shift();
6653
+ currentWirePayload = dispatchBootInjected();
6450
6654
  continue;
6451
6655
  }
6452
6656
  // Wait for the next message — same as after a successful turn
@@ -8816,6 +9020,7 @@ exports.chat = {
8816
9020
  cleanupAbortedParts,
8817
9021
  /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
8818
9022
  defer: chatDefer,
9023
+ deferBeforeOutput: chatDeferBeforeOutput,
8819
9024
  /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */
8820
9025
  inject: injectBackgroundContext,
8821
9026
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */