@trigger.dev/sdk 4.5.0-rc.5 → 4.5.0-rc.6

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 (37) hide show
  1. package/dist/commonjs/v3/ai.d.ts +7 -0
  2. package/dist/commonjs/v3/ai.js +303 -106
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat-client.js +3 -0
  5. package/dist/commonjs/v3/chat-client.js.map +1 -1
  6. package/dist/commonjs/v3/chat-react.js +10 -7
  7. package/dist/commonjs/v3/chat-react.js.map +1 -1
  8. package/dist/commonjs/v3/chat.js +34 -6
  9. package/dist/commonjs/v3/chat.js.map +1 -1
  10. package/dist/commonjs/v3/chat.test.js +53 -0
  11. package/dist/commonjs/v3/chat.test.js.map +1 -1
  12. package/dist/commonjs/v3/sessions.d.ts +8 -4
  13. package/dist/commonjs/v3/sessions.js +7 -3
  14. package/dist/commonjs/v3/sessions.js.map +1 -1
  15. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +6 -0
  16. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  17. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  18. package/dist/commonjs/version.js +1 -1
  19. package/dist/esm/v3/ai.d.ts +7 -0
  20. package/dist/esm/v3/ai.js +303 -107
  21. package/dist/esm/v3/ai.js.map +1 -1
  22. package/dist/esm/v3/chat-client.js +3 -0
  23. package/dist/esm/v3/chat-client.js.map +1 -1
  24. package/dist/esm/v3/chat-react.js +10 -7
  25. package/dist/esm/v3/chat-react.js.map +1 -1
  26. package/dist/esm/v3/chat.js +34 -6
  27. package/dist/esm/v3/chat.js.map +1 -1
  28. package/dist/esm/v3/chat.test.js +53 -0
  29. package/dist/esm/v3/chat.test.js.map +1 -1
  30. package/dist/esm/v3/sessions.d.ts +8 -4
  31. package/dist/esm/v3/sessions.js +7 -3
  32. package/dist/esm/v3/sessions.js.map +1 -1
  33. package/dist/esm/v3/test/mock-chat-agent.d.ts +6 -0
  34. package/dist/esm/v3/test/mock-chat-agent.js +1 -0
  35. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  36. package/dist/esm/version.js +1 -1
  37. package/package.json +4 -3
@@ -1263,6 +1263,13 @@ export type TurnCompleteEvent<TClientData = unknown, TUIM extends UIMessage = UI
1263
1263
  * manual `pipeChat()` or an aborted stream).
1264
1264
  */
1265
1265
  finishReason?: FinishReason;
1266
+ /**
1267
+ * Set when the turn failed (the `run()` body or a lifecycle hook threw).
1268
+ * On an errored turn `responseMessage` is undefined or partial and
1269
+ * `finishReason` is `"error"`. Use this to mark the turn failed in your
1270
+ * persistence. Undefined on a successful turn.
1271
+ */
1272
+ error?: unknown;
1266
1273
  };
1267
1274
  /**
1268
1275
  * Event passed to the `onBeforeTurnComplete` callback.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.chat = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = void 0;
4
+ exports.__findLatestSessionInCursorForTests = __findLatestSessionInCursorForTests;
4
5
  exports.__setReadChatSnapshotImplForTests = __setReadChatSnapshotImplForTests;
5
6
  exports.__setWriteChatSnapshotImplForTests = __setWriteChatSnapshotImplForTests;
6
7
  exports.__readChatSnapshotProductionPathForTests = __readChatSnapshotProductionPathForTests;
@@ -81,51 +82,41 @@ const lastTurnCompleteSeqNumKey = locals_js_1.locals.create("chat.lastTurnComple
81
82
  * the `.in` subscription so already-processed user messages don't get
82
83
  * replayed from S2.
83
84
  *
84
- * Implementation streams the SSE endpoint and listens for `turn-complete`
85
- * via the transport's `onControl` callback; the data-chunk for-await is
86
- * just there to drive the stream. The scan is O(1 turn) because
87
- * `session.out` is bounded to roughly one turn at steady state every
88
- * successful turn-complete is followed by an S2 trim back to the
89
- * previous one (see `writeTurnCompleteChunk`).
85
+ * Implementation is a non-blocking records read (`wait=0`) the
86
+ * endpoint returns everything currently stored (including pre-trim
87
+ * records, since S2 trims are eventually consistent) in one shot, and
88
+ * we keep the LAST matching header. The previous SSE-based scan had to
89
+ * idle-wait a full 5s window to know it reached the tail, which put a
90
+ * constant ~6s tax on every continuation boot.
90
91
  *
91
92
  * Returns `undefined` if no `turn-complete` carrying the header has been
92
93
  * written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
93
- * or a `turn-complete` written before this header existed (cross-version
94
- * boot). Callers fall back to subscribing `.in` from seq 0 in that case;
95
- * the slim-wire merge handles any dedup against snapshot-restored
96
- * messages.
94
+ * a `turn-complete` written before this header existed, or a server old
95
+ * enough that the records endpoint doesn't serialize headers. Callers
96
+ * fall back to subscribing `.in` from seq 0 in that case; the slim-wire
97
+ * merge handles any dedup against snapshot-restored messages.
97
98
  * @internal
98
99
  */
99
100
  async function findLatestSessionInCursor(chatId) {
100
101
  const apiClient = v3_1.apiClientManager.clientOrThrow();
102
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
101
103
  let latestCursor;
102
- const stream = await apiClient.subscribeToSessionStream(chatId, "out", {
103
- // 5s rather than 1s: S2 trim is eventually-consistent (10-60s
104
- // window), so a worker booting just after a trim could still see
105
- // pre-trim records and need a bit longer to drain them all before
106
- // the SSE long-poll closes. Without enough headroom the scan would
107
- // fall back to `undefined`, the `.in` cursor wouldn't be seeded,
108
- // and the next subscribe would replay messages already processed.
109
- timeoutInSeconds: 5,
110
- onControl: (event) => {
111
- if (event.subtype !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
112
- return;
113
- const raw = (0, v3_1.headerValue)(event.headers, v3_1.SESSION_IN_EVENT_ID_HEADER);
114
- if (!raw)
115
- return;
116
- const parsed = Number.parseInt(raw, 10);
117
- if (Number.isFinite(parsed))
118
- latestCursor = parsed;
119
- },
120
- });
121
- // Drain the stream so the underlying SSE reader runs to completion. We
122
- // don't accumulate chunks; `onControl` fires inline as turn-complete
123
- // records arrive.
124
- for await (const _ of stream) {
125
- // intentionally empty
104
+ for (const record of response.records) {
105
+ if ((0, v3_1.controlSubtype)(record.headers) !== v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
106
+ continue;
107
+ const raw = (0, v3_1.headerValue)(record.headers, v3_1.SESSION_IN_EVENT_ID_HEADER);
108
+ if (!raw)
109
+ continue;
110
+ const parsed = Number.parseInt(raw, 10);
111
+ if (Number.isFinite(parsed))
112
+ latestCursor = parsed;
126
113
  }
127
114
  return latestCursor;
128
115
  }
116
+ /** Test-only entry point for the records-based cursor scan. @internal */
117
+ async function __findLatestSessionInCursorForTests(chatId) {
118
+ return findLatestSessionInCursor(chatId);
119
+ }
129
120
  let readChatSnapshotImpl;
130
121
  function __setReadChatSnapshotImplForTests(impl) {
131
122
  readChatSnapshotImpl = impl;
@@ -993,8 +984,15 @@ const messagesInput = {
993
984
  on(handler) {
994
985
  return getChatSession().in.on((chunk) => {
995
986
  if (chunk.kind === "message") {
996
- return handler(chunk.payload);
987
+ // Returning `true` marks the record CONSUMED at the manager level:
988
+ // it is neither buffered for a later `once()` nor re-delivered by
989
+ // the buffer drain when the next turn re-attaches its handler.
990
+ // Without this, a message arriving mid-stream was delivered twice
991
+ // and ran a duplicate turn.
992
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
993
+ return true;
997
994
  }
995
+ return undefined;
998
996
  });
999
997
  },
1000
998
  once(options) {
@@ -1088,8 +1086,13 @@ const stopInput = {
1088
1086
  on(handler) {
1089
1087
  return getChatSession().in.on((chunk) => {
1090
1088
  if (chunk.kind === "stop") {
1091
- return handler({ stop: true, message: chunk.message });
1089
+ // Consume stop records (see the messages facade above). A stop is
1090
+ // only meaningful to the turn it interrupts — buffering it would
1091
+ // let a stale stop abort a future turn.
1092
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1093
+ return true;
1092
1094
  }
1095
+ return undefined;
1093
1096
  });
1094
1097
  },
1095
1098
  once(options) {
@@ -1243,6 +1246,9 @@ const chatHandoverIsFinalKey = locals_js_1.locals.create("chat.handoverIsFinal")
1243
1246
  * `tool-approval-response` rows are AI-SDK-internal and don't need a
1244
1247
  * UIMessage representation. We map:
1245
1248
  * - `text` parts → `{ type: "text", text }`
1249
+ * - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
1250
+ * (provider metadata carried so an Anthropic thinking signature
1251
+ * survives a UIMessage → ModelMessage round trip)
1246
1252
  * - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
1247
1253
  * state: "input-available", input }`
1248
1254
  * - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1259,6 +1265,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1259
1265
  if (part.type === "text" && typeof part.text === "string") {
1260
1266
  parts.push({ type: "text", text: part.text });
1261
1267
  }
1268
+ else if (part.type === "reasoning" && typeof part.text === "string") {
1269
+ parts.push({
1270
+ type: "reasoning",
1271
+ text: part.text,
1272
+ state: "done",
1273
+ ...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
1274
+ });
1275
+ }
1262
1276
  else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
1263
1277
  parts.push({
1264
1278
  type: `tool-${part.toolName}`,
@@ -2605,6 +2619,11 @@ function chatCustomAgent(options) {
2605
2619
  // No client-side upsert needed.
2606
2620
  locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
2607
2621
  locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
2622
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2623
+ // trims `session.out` back to the previous turn boundary. Without
2624
+ // this the slot is undefined and the trim never runs, so `.out`
2625
+ // grows without bound for the whole custom-agent surface.
2626
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
2608
2627
  (0, streams_js_1.markChatAgentRunForStreamsWarning)();
2609
2628
  v3_1.taskContext.setConversationId(payload.chatId);
2610
2629
  stampConversationIdOnActiveSpan(payload.chatId);
@@ -2730,6 +2749,11 @@ function chatAgent(options) {
2730
2749
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2731
2750
  const bootInjectedQueue = [];
2732
2751
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2752
+ // `.in` resume cursor, computed at most once per boot. The boot
2753
+ // block below resolves it (snapshot field or records scan) and the
2754
+ // resume-cursor block reuses it instead of re-scanning.
2755
+ let bootInCursor;
2756
+ let bootInCursorResolved = false;
2733
2757
  if (!hydrateMessages && couldHavePriorState) {
2734
2758
  // Single parent span for the whole boot read phase — snapshot
2735
2759
  // read, session.out replay, session.in replay. Per-phase timing
@@ -2765,23 +2789,28 @@ function chatAgent(options) {
2765
2789
  slot.value = seeded;
2766
2790
  }
2767
2791
  }
2768
- // session.out replay
2769
- const replayOutStart = Date.now();
2770
- try {
2771
- const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2772
- replayedSettled = replayResult.settled;
2773
- replayedPartial = replayResult.partial;
2774
- replayedPartialRaw = replayResult.partialRaw;
2775
- }
2776
- catch (error) {
2777
- v3_1.logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2778
- error: error instanceof Error ? error.message : String(error),
2779
- sessionId: sessionIdForSnapshot,
2780
- });
2781
- }
2782
- bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2783
- bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2784
- bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2792
+ // The `.out` replay and the `.in` cursor + tail read are
2793
+ // independent (both depend only on the snapshot) — run them
2794
+ // concurrently. Each phase keeps its own catch + duration
2795
+ // attribute.
2796
+ const replayOutPhase = async () => {
2797
+ const replayOutStart = Date.now();
2798
+ try {
2799
+ const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2800
+ replayedSettled = replayResult.settled;
2801
+ replayedPartial = replayResult.partial;
2802
+ replayedPartialRaw = replayResult.partialRaw;
2803
+ }
2804
+ catch (error) {
2805
+ v3_1.logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2806
+ error: error instanceof Error ? error.message : String(error),
2807
+ sessionId: sessionIdForSnapshot,
2808
+ });
2809
+ }
2810
+ bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2811
+ bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2812
+ bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2813
+ };
2785
2814
  // session.in tail read
2786
2815
  //
2787
2816
  // session.in carries the user-side of the conversation
@@ -2792,20 +2821,43 @@ function chatAgent(options) {
2792
2821
  // visible via the live SSE subscription — by which point they
2793
2822
  // would arrive AFTER the partial-assistant orphan and look like
2794
2823
  // brand-new turns to the model, producing inverted chains.
2795
- const replayInStart = Date.now();
2796
- const lastInEventId = await findLatestSessionInCursor(payload.chatId)
2797
- .then((cursor) => (cursor !== undefined ? String(cursor) : undefined))
2798
- .catch(() => undefined);
2799
- try {
2800
- replayedInTail = await replaySessionInTail(payload.chatId, {
2801
- lastEventId: lastInEventId,
2802
- });
2803
- }
2804
- catch (error) {
2805
- v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2806
- }
2807
- bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2808
- bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2824
+ //
2825
+ // The cursor comes from the snapshot when present (written
2826
+ // there since `lastInEventId` was added) otherwise from a
2827
+ // records scan of `.out`'s latest turn-complete header.
2828
+ const replayInPhase = async () => {
2829
+ const replayInStart = Date.now();
2830
+ const snapshotInCursor = bootSnapshot?.lastInEventId !== undefined
2831
+ ? Number.parseInt(bootSnapshot.lastInEventId, 10)
2832
+ : undefined;
2833
+ if (snapshotInCursor !== undefined && Number.isFinite(snapshotInCursor)) {
2834
+ bootInCursor = snapshotInCursor;
2835
+ bootInCursorResolved = true;
2836
+ }
2837
+ else {
2838
+ try {
2839
+ bootInCursor = await findLatestSessionInCursor(payload.chatId);
2840
+ bootInCursorResolved = true;
2841
+ }
2842
+ catch {
2843
+ // Transient scan failure: leave unresolved so the
2844
+ // resume-cursor block below retries the lookup.
2845
+ bootInCursor = undefined;
2846
+ }
2847
+ }
2848
+ bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
2849
+ try {
2850
+ replayedInTail = await replaySessionInTail(payload.chatId, {
2851
+ lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
2852
+ });
2853
+ }
2854
+ catch (error) {
2855
+ v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2856
+ }
2857
+ bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2858
+ bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2859
+ };
2860
+ await Promise.all([replayOutPhase(), replayInPhase()]);
2809
2861
  }, {
2810
2862
  attributes: {
2811
2863
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
@@ -2846,7 +2898,12 @@ function chatAgent(options) {
2846
2898
  bootSnapshot !== undefined;
2847
2899
  if (needsResumeCursor) {
2848
2900
  try {
2849
- const cursor = await findLatestSessionInCursor(payload.chatId);
2901
+ // Reuse the cursor the boot block already resolved (snapshot
2902
+ // field or records scan) — only scan here when the boot block
2903
+ // was skipped (hydrateMessages, or snapshot-only signals).
2904
+ const cursor = bootInCursorResolved
2905
+ ? bootInCursor
2906
+ : await findLatestSessionInCursor(payload.chatId);
2850
2907
  if (cursor !== undefined) {
2851
2908
  v3_1.sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
2852
2909
  v3_1.sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
@@ -3610,6 +3667,18 @@ function chatAgent(options) {
3610
3667
  // therefore a delta merge, not a full-history reset.
3611
3668
  if (currentWirePayload.trigger !== "action") {
3612
3669
  let cleanedUIMessages = cleanedIncomingMessages;
3670
+ // Turn-0 head-start with hydrateMessages: the boot seeding from
3671
+ // `payload.headStartMessages` is non-hydrate-only, so ship the
3672
+ // route handler's first-turn history to the hydrate hook as
3673
+ // incoming messages instead (gated on the pending handover).
3674
+ if (turn === 0 &&
3675
+ hydrateMessages &&
3676
+ cleanedUIMessages.length === 0 &&
3677
+ (locals_js_1.locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
3678
+ Array.isArray(payload.headStartMessages) &&
3679
+ payload.headStartMessages.length > 0) {
3680
+ cleanedUIMessages = payload.headStartMessages;
3681
+ }
3613
3682
  // Validate/transform UIMessages before conversion — catches malformed
3614
3683
  // messages from storage or untrusted input before they reach the model.
3615
3684
  // Slim wire: triggers like `regenerate-message` carry no incoming
@@ -3788,40 +3857,47 @@ function chatAgent(options) {
3788
3857
  // `preload` / `close` / `handover-prepare` and submits
3789
3858
  // with no incoming message fall through with the boot-
3790
3859
  // seeded accumulator unchanged.
3791
- if (turn === 0) {
3792
- // Head-start handover splice (turn 0 only): the
3793
- // `chat.handover` route handler signalled a mid-turn
3794
- // handover, so splice its partial assistant response
3795
- // (text + pending tool-calls + the synthesized
3796
- // tool-approval round) onto the accumulator.
3797
- // `streamText` then hits AI SDK's initial-tool-
3798
- // execution branch, runs the agent-side tool executes,
3799
- // and resumes from step 2 — skipping the first model
3800
- // call (already done by the handler).
3801
- //
3802
- // We also synthesize a UIMessage form of the partial
3803
- // assistant and push it to `accumulatedUIMessages` so
3804
- // AI SDK's `processUIMessageStream` (invoked when the
3805
- // run loop calls `runResult.toUIMessageStream({
3806
- // onFinish })`) can initialize `state.message` from
3807
- // the trailing assistant in `originalMessages`. Without
3808
- // that, the `tool-output-available` chunks emitted by
3809
- // the initial-tool-execution branch can't find their
3810
- // matching tool-call in state and AI SDK throws
3811
- // `UIMessageStreamError: No tool invocation found`.
3812
- const pendingHandoverPartial = locals_js_1.locals.get(chatHandoverPartialKey);
3813
- if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3860
+ }
3861
+ if (turn === 0) {
3862
+ // Head-start handover splice (turn 0 only, BOTH
3863
+ // accumulation branches hydrate and default): the
3864
+ // `chat.handover` route handler signalled a mid-turn
3865
+ // handover, so splice its partial assistant response
3866
+ // (text + pending tool-calls + the synthesized
3867
+ // tool-approval round) onto the accumulator.
3868
+ // `streamText` then hits AI SDK's initial-tool-
3869
+ // execution branch, runs the agent-side tool executes,
3870
+ // and resumes from step 2 — skipping the first model
3871
+ // call (already done by the handler).
3872
+ //
3873
+ // We also synthesize a UIMessage form of the partial
3874
+ // assistant and push it to `accumulatedUIMessages` so
3875
+ // AI SDK's `processUIMessageStream` (invoked when the
3876
+ // run loop calls `runResult.toUIMessageStream({
3877
+ // onFinish })`) can initialize `state.message` from
3878
+ // the trailing assistant in `originalMessages`. Without
3879
+ // that, the `tool-output-available` chunks emitted by
3880
+ // the initial-tool-execution branch can't find their
3881
+ // matching tool-call in state and AI SDK throws
3882
+ // `UIMessageStreamError: No tool invocation found`.
3883
+ const pendingHandoverPartial = locals_js_1.locals.get(chatHandoverPartialKey);
3884
+ if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3885
+ const handoverMessageId = locals_js_1.locals.get(chatHandoverMessageIdKey);
3886
+ // Skip if the hydrated chain already persisted the
3887
+ // partial under the handover messageId.
3888
+ const alreadyInChain = handoverMessageId !== undefined &&
3889
+ accumulatedUIMessages.some((m) => m.id === handoverMessageId);
3890
+ if (!alreadyInChain) {
3814
3891
  accumulatedMessages.push(...pendingHandoverPartial);
3815
- const handoverMessageId = locals_js_1.locals.get(chatHandoverMessageIdKey);
3816
3892
  const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
3817
3893
  if (partialUI) {
3818
3894
  accumulatedUIMessages.push(partialUI);
3819
3895
  }
3820
- locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
3821
3896
  }
3897
+ locals_js_1.locals.set(chatHandoverPartialKey, []); // consume once
3822
3898
  }
3823
- locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3824
3899
  }
3900
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3825
3901
  } // end if (trigger !== "action")
3826
3902
  // ── Action result handling ──────────────────────────────
3827
3903
  // For action turns, skip the turn machinery entirely.
@@ -4518,11 +4594,15 @@ function chatAgent(options) {
4518
4594
  if (!hydrateMessages) {
4519
4595
  try {
4520
4596
  await tracer_js_1.tracer.startActiveSpan("snapshot.write", async () => {
4597
+ const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4521
4598
  await writeChatSnapshot(sessionIdForSnapshot, {
4522
4599
  version: 1,
4523
4600
  savedAt: Date.now(),
4524
4601
  messages: accumulatedUIMessages,
4525
4602
  lastOutEventId: turnCompleteResult?.lastEventId,
4603
+ lastInEventId: snapshotInCursor !== undefined
4604
+ ? String(snapshotInCursor)
4605
+ : undefined,
4526
4606
  });
4527
4607
  }, {
4528
4608
  attributes: {
@@ -4659,17 +4739,100 @@ function chatAgent(options) {
4659
4739
  if (turnError instanceof v3_1.OutOfMemoryError) {
4660
4740
  throw turnError;
4661
4741
  }
4742
+ let errorTurnCompleteResult;
4662
4743
  try {
4663
4744
  await withChatWriter(async (writer) => {
4664
4745
  const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
4665
4746
  writer.write({ type: "error", errorText });
4666
4747
  });
4667
4748
  // Signal turn complete so the client knows this turn is done
4668
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4749
+ errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
4669
4750
  }
4670
4751
  catch {
4671
4752
  // Best-effort — if stream write fails, let the run continue anyway
4672
4753
  }
4754
+ // The submit-message merge into the accumulator may not have run
4755
+ // yet (a pre-run hook threw), so fold the wire message in for the
4756
+ // error event + snapshot — the cursor has already advanced past it,
4757
+ // so otherwise it survives in neither the snapshot nor the `.in` tail.
4758
+ const erroredWireMessage = currentWirePayload.message;
4759
+ const erroredUIMessages = erroredWireMessage &&
4760
+ !accumulatedUIMessages.some((m) => m.id === erroredWireMessage.id)
4761
+ ? [...accumulatedUIMessages, erroredWireMessage]
4762
+ : accumulatedUIMessages;
4763
+ // Fire onTurnComplete on the error path too — the docs promise it
4764
+ // runs "after every turn, successful or errored" so customers can
4765
+ // mark the turn failed. `responseMessage` is undefined/partial and
4766
+ // `error` carries the thrown value.
4767
+ if (onTurnComplete) {
4768
+ try {
4769
+ await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
4770
+ await onTurnComplete({
4771
+ ctx,
4772
+ chatId: currentWirePayload.chatId,
4773
+ messages: accumulatedMessages,
4774
+ uiMessages: erroredUIMessages,
4775
+ newMessages: [],
4776
+ newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
4777
+ responseMessage: undefined,
4778
+ rawResponseMessage: undefined,
4779
+ turn,
4780
+ runId: ctx.run.id,
4781
+ chatAccessToken: "",
4782
+ // Parsed `clientData` isn't reliably in scope here (parsing
4783
+ // may itself be the failure), and the raw metadata is the
4784
+ // wrong shape — leave it undefined on the error path.
4785
+ clientData: undefined,
4786
+ stopped: false,
4787
+ continuation,
4788
+ previousRunId,
4789
+ preloaded,
4790
+ totalUsage: cumulativeUsage,
4791
+ finishReason: "error",
4792
+ error: turnError,
4793
+ lastEventId: errorTurnCompleteResult?.lastEventId,
4794
+ });
4795
+ }, {
4796
+ attributes: {
4797
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
4798
+ [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
4799
+ "chat.id": currentWirePayload.chatId,
4800
+ "chat.turn": turn + 1,
4801
+ "chat.errored": true,
4802
+ },
4803
+ });
4804
+ }
4805
+ catch {
4806
+ // A throwing onTurnComplete on the error path must not crash
4807
+ // the run — keep the conversation alive for the next message.
4808
+ }
4809
+ }
4810
+ // Persist a snapshot so the failed turn's user message isn't
4811
+ // stranded. `writeTurnCompleteChunk` already advanced the `.in`
4812
+ // cursor past it (via the session-in-event-id header), and the
4813
+ // success-path snapshot write is skipped on error — without this
4814
+ // the next boot would resume past a message that exists in
4815
+ // neither the snapshot nor the replayable `.in` tail.
4816
+ if (!hydrateMessages) {
4817
+ try {
4818
+ const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4819
+ await writeChatSnapshot(sessionIdForSnapshot, {
4820
+ version: 1,
4821
+ savedAt: Date.now(),
4822
+ messages: erroredUIMessages,
4823
+ lastOutEventId: errorTurnCompleteResult?.lastEventId,
4824
+ lastInEventId: errorSnapshotInCursor !== undefined
4825
+ ? String(errorSnapshotInCursor)
4826
+ : undefined,
4827
+ });
4828
+ }
4829
+ catch (error) {
4830
+ v3_1.logger.warn("chat.agent: error-path snapshot write failed", {
4831
+ error: error instanceof Error ? error.message : String(error),
4832
+ sessionId: sessionIdForSnapshot,
4833
+ });
4834
+ }
4835
+ }
4673
4836
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
4674
4837
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) ||
4675
4838
  locals_js_1.locals.get(chatEndRunRequestedKey)) {
@@ -5535,18 +5698,32 @@ function createChatSession(payload, options) {
5535
5698
  return {
5536
5699
  async next() {
5537
5700
  turn++;
5538
- // First turn: handle preload wait for the first real message
5539
- if (turn === 0 && currentPayload.trigger === "preload") {
5701
+ // First turn: wait when the boot payload carries no message.
5702
+ // Preload boots wait for the first real message; continuation
5703
+ // boots (fresh run via `ensureRunForSession` / end-and-continue)
5704
+ // arrive with the sticky boot-payload fields stripped, so running
5705
+ // a turn immediately would invoke the model with no user input.
5706
+ const isMessagelessContinuationBoot = currentPayload.continuation === true && !currentPayload.message;
5707
+ if (turn === 0 && (currentPayload.trigger === "preload" || isMessagelessContinuationBoot)) {
5540
5708
  const result = await messagesInput.waitWithIdleTimeout({
5541
5709
  idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
5542
5710
  timeout,
5543
- spanName: "waiting for first message",
5711
+ spanName: currentPayload.trigger === "preload"
5712
+ ? "waiting for first message"
5713
+ : "waiting for first message (continuation)",
5544
5714
  });
5545
5715
  if (!result.ok || runSignal.aborted) {
5546
5716
  stop.cleanup();
5547
5717
  return { done: true, value: undefined };
5548
5718
  }
5719
+ const continuationBoot = isMessagelessContinuationBoot;
5549
5720
  currentPayload = result.output;
5721
+ // Preserve the continuation flag — the wire payload of the next
5722
+ // message doesn't carry it, and `turn.continuation` is how the
5723
+ // user knows to seed history (e.g. `turn.setMessages(stored)`).
5724
+ if (continuationBoot && currentPayload.continuation === undefined) {
5725
+ currentPayload = { ...currentPayload, continuation: true };
5726
+ }
5550
5727
  }
5551
5728
  // Subsequent turns: wait for the next message
5552
5729
  if (turn > 0) {
@@ -5699,14 +5876,22 @@ function createChatSession(payload, options) {
5699
5876
  locals_js_1.locals.set(chatResponsePartsKey, []);
5700
5877
  }
5701
5878
  }
5702
- // Capture token usage from the streamText result
5879
+ // Capture token usage from the streamText result. Race with a 2s
5880
+ // timeout — on stop-abort the AI SDK's totalUsage promise can hang
5881
+ // indefinitely, which would wedge the turn loop (same guard as
5882
+ // chat.agent's turn loop).
5703
5883
  let turnUsage;
5704
5884
  if (typeof source.totalUsage?.then === "function") {
5705
5885
  try {
5706
- const usage = await source.totalUsage;
5707
- turnUsage = usage;
5708
- previousTurnUsage = usage;
5709
- cumulativeUsage = addUsage(cumulativeUsage, usage);
5886
+ const usage = (await Promise.race([
5887
+ source.totalUsage,
5888
+ new Promise((r) => setTimeout(() => r(undefined), 2_000)),
5889
+ ]));
5890
+ if (usage) {
5891
+ turnUsage = usage;
5892
+ previousTurnUsage = usage;
5893
+ cumulativeUsage = addUsage(cumulativeUsage, usage);
5894
+ }
5710
5895
  }
5711
5896
  catch {
5712
5897
  /* non-fatal */
@@ -6049,7 +6234,8 @@ function createChatStartSessionAction(taskId, options) {
6049
6234
  // run-list filter by chat works without the customer having to wire it
6050
6235
  // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6051
6236
  const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6052
- const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
6237
+ // Platform cap is 10 tags per run; the auto chat tag takes one slot.
6238
+ const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 10);
6053
6239
  const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
6054
6240
  const triggerConfig = {
6055
6241
  basePayload: {
@@ -6347,8 +6533,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
6347
6533
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
6348
6534
  // first-turn-ever (or first turn post-OOM without a snapshot seed) is
6349
6535
  // fine — the chain catches up next turn.
6350
- const slot = locals_js_1.locals.get(lastTurnCompleteSeqNumKey);
6351
- const prev = slot?.value;
6536
+ //
6537
+ // Lazily create the slot if a caller reached here without one (a plain
6538
+ // `task()` driving `chat.createSession` / `chat.writeTurnComplete`, vs.
6539
+ // chatAgent/chatCustomAgent which seed it at boot). The first call then
6540
+ // does no trim (nothing before it) and records its seq; later calls trim
6541
+ // — so `.out` is bounded for every writeTurnComplete caller, not just the
6542
+ // built-in agents.
6543
+ let slot = locals_js_1.locals.get(lastTurnCompleteSeqNumKey);
6544
+ if (!slot) {
6545
+ slot = { value: undefined };
6546
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, slot);
6547
+ }
6548
+ const prev = slot.value;
6352
6549
  if (slot && prev !== undefined) {
6353
6550
  try {
6354
6551
  await session.out.trimTo(prev);