@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
package/dist/esm/v3/ai.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessoryAttributes, apiClientManager, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } from "@trigger.dev/core/v3";
1
+ import { accessoryAttributes, apiClientManager, controlSubtype, getSchemaParseFn, headerValue, InputStreamOncePromise, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, sessionStreams, SemanticInternalAttributes, taskContext, SESSION_IN_EVENT_ID_HEADER, TRIGGER_CONTROL_SUBTYPE, generateJWT, } from "@trigger.dev/core/v3";
2
2
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
3
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
4
  import { convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, tool as aiTool, zodSchema, } from "../imports/ai-runtime.js";
@@ -68,51 +68,41 @@ const lastTurnCompleteSeqNumKey = locals.create("chat.lastTurnCompleteSeqNum");
68
68
  * the `.in` subscription so already-processed user messages don't get
69
69
  * replayed from S2.
70
70
  *
71
- * Implementation streams the SSE endpoint and listens for `turn-complete`
72
- * via the transport's `onControl` callback; the data-chunk for-await is
73
- * just there to drive the stream. The scan is O(1 turn) because
74
- * `session.out` is bounded to roughly one turn at steady state every
75
- * successful turn-complete is followed by an S2 trim back to the
76
- * previous one (see `writeTurnCompleteChunk`).
71
+ * Implementation is a non-blocking records read (`wait=0`) the
72
+ * endpoint returns everything currently stored (including pre-trim
73
+ * records, since S2 trims are eventually consistent) in one shot, and
74
+ * we keep the LAST matching header. The previous SSE-based scan had to
75
+ * idle-wait a full 5s window to know it reached the tail, which put a
76
+ * constant ~6s tax on every continuation boot.
77
77
  *
78
78
  * Returns `undefined` if no `turn-complete` carrying the header has been
79
79
  * written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
80
- * or a `turn-complete` written before this header existed (cross-version
81
- * boot). Callers fall back to subscribing `.in` from seq 0 in that case;
82
- * the slim-wire merge handles any dedup against snapshot-restored
83
- * messages.
80
+ * a `turn-complete` written before this header existed, or a server old
81
+ * enough that the records endpoint doesn't serialize headers. Callers
82
+ * fall back to subscribing `.in` from seq 0 in that case; the slim-wire
83
+ * merge handles any dedup against snapshot-restored messages.
84
84
  * @internal
85
85
  */
86
86
  async function findLatestSessionInCursor(chatId) {
87
87
  const apiClient = apiClientManager.clientOrThrow();
88
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
88
89
  let latestCursor;
89
- const stream = await apiClient.subscribeToSessionStream(chatId, "out", {
90
- // 5s rather than 1s: S2 trim is eventually-consistent (10-60s
91
- // window), so a worker booting just after a trim could still see
92
- // pre-trim records and need a bit longer to drain them all before
93
- // the SSE long-poll closes. Without enough headroom the scan would
94
- // fall back to `undefined`, the `.in` cursor wouldn't be seeded,
95
- // and the next subscribe would replay messages already processed.
96
- timeoutInSeconds: 5,
97
- onControl: (event) => {
98
- if (event.subtype !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
99
- return;
100
- const raw = headerValue(event.headers, SESSION_IN_EVENT_ID_HEADER);
101
- if (!raw)
102
- return;
103
- const parsed = Number.parseInt(raw, 10);
104
- if (Number.isFinite(parsed))
105
- latestCursor = parsed;
106
- },
107
- });
108
- // Drain the stream so the underlying SSE reader runs to completion. We
109
- // don't accumulate chunks; `onControl` fires inline as turn-complete
110
- // records arrive.
111
- for await (const _ of stream) {
112
- // intentionally empty
90
+ for (const record of response.records) {
91
+ if (controlSubtype(record.headers) !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
92
+ continue;
93
+ const raw = headerValue(record.headers, SESSION_IN_EVENT_ID_HEADER);
94
+ if (!raw)
95
+ continue;
96
+ const parsed = Number.parseInt(raw, 10);
97
+ if (Number.isFinite(parsed))
98
+ latestCursor = parsed;
113
99
  }
114
100
  return latestCursor;
115
101
  }
102
+ /** Test-only entry point for the records-based cursor scan. @internal */
103
+ export async function __findLatestSessionInCursorForTests(chatId) {
104
+ return findLatestSessionInCursor(chatId);
105
+ }
116
106
  let readChatSnapshotImpl;
117
107
  export function __setReadChatSnapshotImplForTests(impl) {
118
108
  readChatSnapshotImpl = impl;
@@ -980,8 +970,15 @@ const messagesInput = {
980
970
  on(handler) {
981
971
  return getChatSession().in.on((chunk) => {
982
972
  if (chunk.kind === "message") {
983
- return handler(chunk.payload);
973
+ // Returning `true` marks the record CONSUMED at the manager level:
974
+ // it is neither buffered for a later `once()` nor re-delivered by
975
+ // the buffer drain when the next turn re-attaches its handler.
976
+ // Without this, a message arriving mid-stream was delivered twice
977
+ // and ran a duplicate turn.
978
+ void Promise.resolve(handler(chunk.payload)).catch(() => { });
979
+ return true;
984
980
  }
981
+ return undefined;
985
982
  });
986
983
  },
987
984
  once(options) {
@@ -1075,8 +1072,13 @@ const stopInput = {
1075
1072
  on(handler) {
1076
1073
  return getChatSession().in.on((chunk) => {
1077
1074
  if (chunk.kind === "stop") {
1078
- return handler({ stop: true, message: chunk.message });
1075
+ // Consume stop records (see the messages facade above). A stop is
1076
+ // only meaningful to the turn it interrupts — buffering it would
1077
+ // let a stale stop abort a future turn.
1078
+ void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
1079
+ return true;
1079
1080
  }
1081
+ return undefined;
1080
1082
  });
1081
1083
  },
1082
1084
  once(options) {
@@ -1230,6 +1232,9 @@ const chatHandoverIsFinalKey = locals.create("chat.handoverIsFinal");
1230
1232
  * `tool-approval-response` rows are AI-SDK-internal and don't need a
1231
1233
  * UIMessage representation. We map:
1232
1234
  * - `text` parts → `{ type: "text", text }`
1235
+ * - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
1236
+ * (provider metadata carried so an Anthropic thinking signature
1237
+ * survives a UIMessage → ModelMessage round trip)
1233
1238
  * - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
1234
1239
  * state: "input-available", input }`
1235
1240
  * - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1246,6 +1251,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1246
1251
  if (part.type === "text" && typeof part.text === "string") {
1247
1252
  parts.push({ type: "text", text: part.text });
1248
1253
  }
1254
+ else if (part.type === "reasoning" && typeof part.text === "string") {
1255
+ parts.push({
1256
+ type: "reasoning",
1257
+ text: part.text,
1258
+ state: "done",
1259
+ ...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
1260
+ });
1261
+ }
1249
1262
  else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
1250
1263
  parts.push({
1251
1264
  type: `tool-${part.toolName}`,
@@ -2590,6 +2603,11 @@ function chatCustomAgent(options) {
2590
2603
  // No client-side upsert needed.
2591
2604
  locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
2592
2605
  locals.set(chatAgentRunContextKey, runOptions.ctx);
2606
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
2607
+ // trims `session.out` back to the previous turn boundary. Without
2608
+ // this the slot is undefined and the trim never runs, so `.out`
2609
+ // grows without bound for the whole custom-agent surface.
2610
+ locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
2593
2611
  markChatAgentRunForStreamsWarning();
2594
2612
  taskContext.setConversationId(payload.chatId);
2595
2613
  stampConversationIdOnActiveSpan(payload.chatId);
@@ -2715,6 +2733,11 @@ function chatAgent(options) {
2715
2733
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2716
2734
  const bootInjectedQueue = [];
2717
2735
  const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
2736
+ // `.in` resume cursor, computed at most once per boot. The boot
2737
+ // block below resolves it (snapshot field or records scan) and the
2738
+ // resume-cursor block reuses it instead of re-scanning.
2739
+ let bootInCursor;
2740
+ let bootInCursorResolved = false;
2718
2741
  if (!hydrateMessages && couldHavePriorState) {
2719
2742
  // Single parent span for the whole boot read phase — snapshot
2720
2743
  // read, session.out replay, session.in replay. Per-phase timing
@@ -2750,23 +2773,28 @@ function chatAgent(options) {
2750
2773
  slot.value = seeded;
2751
2774
  }
2752
2775
  }
2753
- // session.out replay
2754
- const replayOutStart = Date.now();
2755
- try {
2756
- const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2757
- replayedSettled = replayResult.settled;
2758
- replayedPartial = replayResult.partial;
2759
- replayedPartialRaw = replayResult.partialRaw;
2760
- }
2761
- catch (error) {
2762
- logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2763
- error: error instanceof Error ? error.message : String(error),
2764
- sessionId: sessionIdForSnapshot,
2765
- });
2766
- }
2767
- bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2768
- bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2769
- bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2776
+ // The `.out` replay and the `.in` cursor + tail read are
2777
+ // independent (both depend only on the snapshot) — run them
2778
+ // concurrently. Each phase keeps its own catch + duration
2779
+ // attribute.
2780
+ const replayOutPhase = async () => {
2781
+ const replayOutStart = Date.now();
2782
+ try {
2783
+ const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2784
+ replayedSettled = replayResult.settled;
2785
+ replayedPartial = replayResult.partial;
2786
+ replayedPartialRaw = replayResult.partialRaw;
2787
+ }
2788
+ catch (error) {
2789
+ logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2790
+ error: error instanceof Error ? error.message : String(error),
2791
+ sessionId: sessionIdForSnapshot,
2792
+ });
2793
+ }
2794
+ bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2795
+ bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2796
+ bootSpan.setAttribute("chat.boot.replay.out.partialPresent", replayedPartial !== undefined);
2797
+ };
2770
2798
  // session.in tail read
2771
2799
  //
2772
2800
  // session.in carries the user-side of the conversation
@@ -2777,20 +2805,43 @@ function chatAgent(options) {
2777
2805
  // visible via the live SSE subscription — by which point they
2778
2806
  // would arrive AFTER the partial-assistant orphan and look like
2779
2807
  // brand-new turns to the model, producing inverted chains.
2780
- const replayInStart = Date.now();
2781
- const lastInEventId = await findLatestSessionInCursor(payload.chatId)
2782
- .then((cursor) => (cursor !== undefined ? String(cursor) : undefined))
2783
- .catch(() => undefined);
2784
- try {
2785
- replayedInTail = await replaySessionInTail(payload.chatId, {
2786
- lastEventId: lastInEventId,
2787
- });
2788
- }
2789
- catch (error) {
2790
- logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2791
- }
2792
- bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2793
- bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2808
+ //
2809
+ // The cursor comes from the snapshot when present (written
2810
+ // there since `lastInEventId` was added) otherwise from a
2811
+ // records scan of `.out`'s latest turn-complete header.
2812
+ const replayInPhase = async () => {
2813
+ const replayInStart = Date.now();
2814
+ const snapshotInCursor = bootSnapshot?.lastInEventId !== undefined
2815
+ ? Number.parseInt(bootSnapshot.lastInEventId, 10)
2816
+ : undefined;
2817
+ if (snapshotInCursor !== undefined && Number.isFinite(snapshotInCursor)) {
2818
+ bootInCursor = snapshotInCursor;
2819
+ bootInCursorResolved = true;
2820
+ }
2821
+ else {
2822
+ try {
2823
+ bootInCursor = await findLatestSessionInCursor(payload.chatId);
2824
+ bootInCursorResolved = true;
2825
+ }
2826
+ catch {
2827
+ // Transient scan failure: leave unresolved so the
2828
+ // resume-cursor block below retries the lookup.
2829
+ bootInCursor = undefined;
2830
+ }
2831
+ }
2832
+ bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
2833
+ try {
2834
+ replayedInTail = await replaySessionInTail(payload.chatId, {
2835
+ lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
2836
+ });
2837
+ }
2838
+ catch (error) {
2839
+ logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2840
+ }
2841
+ bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2842
+ bootSpan.setAttribute("chat.boot.replay.in.userCount", replayedInTail.length);
2843
+ };
2844
+ await Promise.all([replayOutPhase(), replayInPhase()]);
2794
2845
  }, {
2795
2846
  attributes: {
2796
2847
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
@@ -2831,7 +2882,12 @@ function chatAgent(options) {
2831
2882
  bootSnapshot !== undefined;
2832
2883
  if (needsResumeCursor) {
2833
2884
  try {
2834
- const cursor = await findLatestSessionInCursor(payload.chatId);
2885
+ // Reuse the cursor the boot block already resolved (snapshot
2886
+ // field or records scan) — only scan here when the boot block
2887
+ // was skipped (hydrateMessages, or snapshot-only signals).
2888
+ const cursor = bootInCursorResolved
2889
+ ? bootInCursor
2890
+ : await findLatestSessionInCursor(payload.chatId);
2835
2891
  if (cursor !== undefined) {
2836
2892
  sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
2837
2893
  sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
@@ -3595,6 +3651,18 @@ function chatAgent(options) {
3595
3651
  // therefore a delta merge, not a full-history reset.
3596
3652
  if (currentWirePayload.trigger !== "action") {
3597
3653
  let cleanedUIMessages = cleanedIncomingMessages;
3654
+ // Turn-0 head-start with hydrateMessages: the boot seeding from
3655
+ // `payload.headStartMessages` is non-hydrate-only, so ship the
3656
+ // route handler's first-turn history to the hydrate hook as
3657
+ // incoming messages instead (gated on the pending handover).
3658
+ if (turn === 0 &&
3659
+ hydrateMessages &&
3660
+ cleanedUIMessages.length === 0 &&
3661
+ (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
3662
+ Array.isArray(payload.headStartMessages) &&
3663
+ payload.headStartMessages.length > 0) {
3664
+ cleanedUIMessages = payload.headStartMessages;
3665
+ }
3598
3666
  // Validate/transform UIMessages before conversion — catches malformed
3599
3667
  // messages from storage or untrusted input before they reach the model.
3600
3668
  // Slim wire: triggers like `regenerate-message` carry no incoming
@@ -3773,40 +3841,47 @@ function chatAgent(options) {
3773
3841
  // `preload` / `close` / `handover-prepare` and submits
3774
3842
  // with no incoming message fall through with the boot-
3775
3843
  // seeded accumulator unchanged.
3776
- if (turn === 0) {
3777
- // Head-start handover splice (turn 0 only): the
3778
- // `chat.handover` route handler signalled a mid-turn
3779
- // handover, so splice its partial assistant response
3780
- // (text + pending tool-calls + the synthesized
3781
- // tool-approval round) onto the accumulator.
3782
- // `streamText` then hits AI SDK's initial-tool-
3783
- // execution branch, runs the agent-side tool executes,
3784
- // and resumes from step 2 — skipping the first model
3785
- // call (already done by the handler).
3786
- //
3787
- // We also synthesize a UIMessage form of the partial
3788
- // assistant and push it to `accumulatedUIMessages` so
3789
- // AI SDK's `processUIMessageStream` (invoked when the
3790
- // run loop calls `runResult.toUIMessageStream({
3791
- // onFinish })`) can initialize `state.message` from
3792
- // the trailing assistant in `originalMessages`. Without
3793
- // that, the `tool-output-available` chunks emitted by
3794
- // the initial-tool-execution branch can't find their
3795
- // matching tool-call in state and AI SDK throws
3796
- // `UIMessageStreamError: No tool invocation found`.
3797
- const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
3798
- if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3844
+ }
3845
+ if (turn === 0) {
3846
+ // Head-start handover splice (turn 0 only, BOTH
3847
+ // accumulation branches hydrate and default): the
3848
+ // `chat.handover` route handler signalled a mid-turn
3849
+ // handover, so splice its partial assistant response
3850
+ // (text + pending tool-calls + the synthesized
3851
+ // tool-approval round) onto the accumulator.
3852
+ // `streamText` then hits AI SDK's initial-tool-
3853
+ // execution branch, runs the agent-side tool executes,
3854
+ // and resumes from step 2 — skipping the first model
3855
+ // call (already done by the handler).
3856
+ //
3857
+ // We also synthesize a UIMessage form of the partial
3858
+ // assistant and push it to `accumulatedUIMessages` so
3859
+ // AI SDK's `processUIMessageStream` (invoked when the
3860
+ // run loop calls `runResult.toUIMessageStream({
3861
+ // onFinish })`) can initialize `state.message` from
3862
+ // the trailing assistant in `originalMessages`. Without
3863
+ // that, the `tool-output-available` chunks emitted by
3864
+ // the initial-tool-execution branch can't find their
3865
+ // matching tool-call in state and AI SDK throws
3866
+ // `UIMessageStreamError: No tool invocation found`.
3867
+ const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
3868
+ if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
3869
+ const handoverMessageId = locals.get(chatHandoverMessageIdKey);
3870
+ // Skip if the hydrated chain already persisted the
3871
+ // partial under the handover messageId.
3872
+ const alreadyInChain = handoverMessageId !== undefined &&
3873
+ accumulatedUIMessages.some((m) => m.id === handoverMessageId);
3874
+ if (!alreadyInChain) {
3799
3875
  accumulatedMessages.push(...pendingHandoverPartial);
3800
- const handoverMessageId = locals.get(chatHandoverMessageIdKey);
3801
3876
  const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
3802
3877
  if (partialUI) {
3803
3878
  accumulatedUIMessages.push(partialUI);
3804
3879
  }
3805
- locals.set(chatHandoverPartialKey, []); // consume once
3806
3880
  }
3881
+ locals.set(chatHandoverPartialKey, []); // consume once
3807
3882
  }
3808
- locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3809
3883
  }
3884
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3810
3885
  } // end if (trigger !== "action")
3811
3886
  // ── Action result handling ──────────────────────────────
3812
3887
  // For action turns, skip the turn machinery entirely.
@@ -4503,11 +4578,15 @@ function chatAgent(options) {
4503
4578
  if (!hydrateMessages) {
4504
4579
  try {
4505
4580
  await tracer.startActiveSpan("snapshot.write", async () => {
4581
+ const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4506
4582
  await writeChatSnapshot(sessionIdForSnapshot, {
4507
4583
  version: 1,
4508
4584
  savedAt: Date.now(),
4509
4585
  messages: accumulatedUIMessages,
4510
4586
  lastOutEventId: turnCompleteResult?.lastEventId,
4587
+ lastInEventId: snapshotInCursor !== undefined
4588
+ ? String(snapshotInCursor)
4589
+ : undefined,
4511
4590
  });
4512
4591
  }, {
4513
4592
  attributes: {
@@ -4644,17 +4723,100 @@ function chatAgent(options) {
4644
4723
  if (turnError instanceof OutOfMemoryError) {
4645
4724
  throw turnError;
4646
4725
  }
4726
+ let errorTurnCompleteResult;
4647
4727
  try {
4648
4728
  await withChatWriter(async (writer) => {
4649
4729
  const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
4650
4730
  writer.write({ type: "error", errorText });
4651
4731
  });
4652
4732
  // Signal turn complete so the client knows this turn is done
4653
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4733
+ errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
4654
4734
  }
4655
4735
  catch {
4656
4736
  // Best-effort — if stream write fails, let the run continue anyway
4657
4737
  }
4738
+ // The submit-message merge into the accumulator may not have run
4739
+ // yet (a pre-run hook threw), so fold the wire message in for the
4740
+ // error event + snapshot — the cursor has already advanced past it,
4741
+ // so otherwise it survives in neither the snapshot nor the `.in` tail.
4742
+ const erroredWireMessage = currentWirePayload.message;
4743
+ const erroredUIMessages = erroredWireMessage &&
4744
+ !accumulatedUIMessages.some((m) => m.id === erroredWireMessage.id)
4745
+ ? [...accumulatedUIMessages, erroredWireMessage]
4746
+ : accumulatedUIMessages;
4747
+ // Fire onTurnComplete on the error path too — the docs promise it
4748
+ // runs "after every turn, successful or errored" so customers can
4749
+ // mark the turn failed. `responseMessage` is undefined/partial and
4750
+ // `error` carries the thrown value.
4751
+ if (onTurnComplete) {
4752
+ try {
4753
+ await tracer.startActiveSpan("onTurnComplete()", async () => {
4754
+ await onTurnComplete({
4755
+ ctx,
4756
+ chatId: currentWirePayload.chatId,
4757
+ messages: accumulatedMessages,
4758
+ uiMessages: erroredUIMessages,
4759
+ newMessages: [],
4760
+ newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
4761
+ responseMessage: undefined,
4762
+ rawResponseMessage: undefined,
4763
+ turn,
4764
+ runId: ctx.run.id,
4765
+ chatAccessToken: "",
4766
+ // Parsed `clientData` isn't reliably in scope here (parsing
4767
+ // may itself be the failure), and the raw metadata is the
4768
+ // wrong shape — leave it undefined on the error path.
4769
+ clientData: undefined,
4770
+ stopped: false,
4771
+ continuation,
4772
+ previousRunId,
4773
+ preloaded,
4774
+ totalUsage: cumulativeUsage,
4775
+ finishReason: "error",
4776
+ error: turnError,
4777
+ lastEventId: errorTurnCompleteResult?.lastEventId,
4778
+ });
4779
+ }, {
4780
+ attributes: {
4781
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
4782
+ [SemanticInternalAttributes.COLLAPSED]: true,
4783
+ "chat.id": currentWirePayload.chatId,
4784
+ "chat.turn": turn + 1,
4785
+ "chat.errored": true,
4786
+ },
4787
+ });
4788
+ }
4789
+ catch {
4790
+ // A throwing onTurnComplete on the error path must not crash
4791
+ // the run — keep the conversation alive for the next message.
4792
+ }
4793
+ }
4794
+ // Persist a snapshot so the failed turn's user message isn't
4795
+ // stranded. `writeTurnCompleteChunk` already advanced the `.in`
4796
+ // cursor past it (via the session-in-event-id header), and the
4797
+ // success-path snapshot write is skipped on error — without this
4798
+ // the next boot would resume past a message that exists in
4799
+ // neither the snapshot nor the replayable `.in` tail.
4800
+ if (!hydrateMessages) {
4801
+ try {
4802
+ const errorSnapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4803
+ await writeChatSnapshot(sessionIdForSnapshot, {
4804
+ version: 1,
4805
+ savedAt: Date.now(),
4806
+ messages: erroredUIMessages,
4807
+ lastOutEventId: errorTurnCompleteResult?.lastEventId,
4808
+ lastInEventId: errorSnapshotInCursor !== undefined
4809
+ ? String(errorSnapshotInCursor)
4810
+ : undefined,
4811
+ });
4812
+ }
4813
+ catch (error) {
4814
+ logger.warn("chat.agent: error-path snapshot write failed", {
4815
+ error: error instanceof Error ? error.message : String(error),
4816
+ sessionId: sessionIdForSnapshot,
4817
+ });
4818
+ }
4819
+ }
4658
4820
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
4659
4821
  if (locals.get(chatUpgradeRequestedKey) ||
4660
4822
  locals.get(chatEndRunRequestedKey)) {
@@ -5520,18 +5682,32 @@ function createChatSession(payload, options) {
5520
5682
  return {
5521
5683
  async next() {
5522
5684
  turn++;
5523
- // First turn: handle preload wait for the first real message
5524
- if (turn === 0 && currentPayload.trigger === "preload") {
5685
+ // First turn: wait when the boot payload carries no message.
5686
+ // Preload boots wait for the first real message; continuation
5687
+ // boots (fresh run via `ensureRunForSession` / end-and-continue)
5688
+ // arrive with the sticky boot-payload fields stripped, so running
5689
+ // a turn immediately would invoke the model with no user input.
5690
+ const isMessagelessContinuationBoot = currentPayload.continuation === true && !currentPayload.message;
5691
+ if (turn === 0 && (currentPayload.trigger === "preload" || isMessagelessContinuationBoot)) {
5525
5692
  const result = await messagesInput.waitWithIdleTimeout({
5526
5693
  idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
5527
5694
  timeout,
5528
- spanName: "waiting for first message",
5695
+ spanName: currentPayload.trigger === "preload"
5696
+ ? "waiting for first message"
5697
+ : "waiting for first message (continuation)",
5529
5698
  });
5530
5699
  if (!result.ok || runSignal.aborted) {
5531
5700
  stop.cleanup();
5532
5701
  return { done: true, value: undefined };
5533
5702
  }
5703
+ const continuationBoot = isMessagelessContinuationBoot;
5534
5704
  currentPayload = result.output;
5705
+ // Preserve the continuation flag — the wire payload of the next
5706
+ // message doesn't carry it, and `turn.continuation` is how the
5707
+ // user knows to seed history (e.g. `turn.setMessages(stored)`).
5708
+ if (continuationBoot && currentPayload.continuation === undefined) {
5709
+ currentPayload = { ...currentPayload, continuation: true };
5710
+ }
5535
5711
  }
5536
5712
  // Subsequent turns: wait for the next message
5537
5713
  if (turn > 0) {
@@ -5684,14 +5860,22 @@ function createChatSession(payload, options) {
5684
5860
  locals.set(chatResponsePartsKey, []);
5685
5861
  }
5686
5862
  }
5687
- // Capture token usage from the streamText result
5863
+ // Capture token usage from the streamText result. Race with a 2s
5864
+ // timeout — on stop-abort the AI SDK's totalUsage promise can hang
5865
+ // indefinitely, which would wedge the turn loop (same guard as
5866
+ // chat.agent's turn loop).
5688
5867
  let turnUsage;
5689
5868
  if (typeof source.totalUsage?.then === "function") {
5690
5869
  try {
5691
- const usage = await source.totalUsage;
5692
- turnUsage = usage;
5693
- previousTurnUsage = usage;
5694
- cumulativeUsage = addUsage(cumulativeUsage, usage);
5870
+ const usage = (await Promise.race([
5871
+ source.totalUsage,
5872
+ new Promise((r) => setTimeout(() => r(undefined), 2_000)),
5873
+ ]));
5874
+ if (usage) {
5875
+ turnUsage = usage;
5876
+ previousTurnUsage = usage;
5877
+ cumulativeUsage = addUsage(cumulativeUsage, usage);
5878
+ }
5695
5879
  }
5696
5880
  catch {
5697
5881
  /* non-fatal */
@@ -6034,7 +6218,8 @@ function createChatStartSessionAction(taskId, options) {
6034
6218
  // run-list filter by chat works without the customer having to wire it
6035
6219
  // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6036
6220
  const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6037
- const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
6221
+ // Platform cap is 10 tags per run; the auto chat tag takes one slot.
6222
+ const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 10);
6038
6223
  const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
6039
6224
  const triggerConfig = {
6040
6225
  basePayload: {
@@ -6332,8 +6517,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
6332
6517
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
6333
6518
  // first-turn-ever (or first turn post-OOM without a snapshot seed) is
6334
6519
  // fine — the chain catches up next turn.
6335
- const slot = locals.get(lastTurnCompleteSeqNumKey);
6336
- const prev = slot?.value;
6520
+ //
6521
+ // Lazily create the slot if a caller reached here without one (a plain
6522
+ // `task()` driving `chat.createSession` / `chat.writeTurnComplete`, vs.
6523
+ // chatAgent/chatCustomAgent which seed it at boot). The first call then
6524
+ // does no trim (nothing before it) and records its seq; later calls trim
6525
+ // — so `.out` is bounded for every writeTurnComplete caller, not just the
6526
+ // built-in agents.
6527
+ let slot = locals.get(lastTurnCompleteSeqNumKey);
6528
+ if (!slot) {
6529
+ slot = { value: undefined };
6530
+ locals.set(lastTurnCompleteSeqNumKey, slot);
6531
+ }
6532
+ const prev = slot.value;
6337
6533
  if (slot && prev !== undefined) {
6338
6534
  try {
6339
6535
  await session.out.trimTo(prev);