@trigger.dev/sdk 4.5.0-rc.4 → 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 (61) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -0
  2. package/dist/commonjs/imports/ai-runtime.d.ts +1 -0
  3. package/dist/commonjs/imports/ai-runtime.js +27 -0
  4. package/dist/commonjs/v3/ai.d.ts +17 -2
  5. package/dist/commonjs/v3/ai.js +349 -139
  6. package/dist/commonjs/v3/ai.js.map +1 -1
  7. package/dist/commonjs/v3/aiAutoTelemetry.d.ts +2 -0
  8. package/dist/commonjs/v3/aiAutoTelemetry.js +81 -0
  9. package/dist/commonjs/v3/aiAutoTelemetry.js.map +1 -0
  10. package/dist/commonjs/v3/chat-client.js +8 -3
  11. package/dist/commonjs/v3/chat-client.js.map +1 -1
  12. package/dist/commonjs/v3/chat-react.js +10 -7
  13. package/dist/commonjs/v3/chat-react.js.map +1 -1
  14. package/dist/commonjs/v3/chat-server.d.ts +29 -6
  15. package/dist/commonjs/v3/chat-server.js +6 -4
  16. package/dist/commonjs/v3/chat-server.js.map +1 -1
  17. package/dist/commonjs/v3/chat.d.ts +11 -0
  18. package/dist/commonjs/v3/chat.js +95 -7
  19. package/dist/commonjs/v3/chat.js.map +1 -1
  20. package/dist/commonjs/v3/chat.test.js +53 -0
  21. package/dist/commonjs/v3/chat.test.js.map +1 -1
  22. package/dist/commonjs/v3/sessions.d.ts +8 -4
  23. package/dist/commonjs/v3/sessions.js +7 -3
  24. package/dist/commonjs/v3/sessions.js.map +1 -1
  25. package/dist/commonjs/v3/shared.js +17 -9
  26. package/dist/commonjs/v3/shared.js.map +1 -1
  27. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +6 -0
  28. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  29. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  30. package/dist/commonjs/version.js +1 -1
  31. package/dist/esm/imports/ai-runtime.d.ts +2 -0
  32. package/dist/esm/imports/ai-runtime.js +16 -0
  33. package/dist/esm/imports/ai-runtime.js.map +1 -0
  34. package/dist/esm/v3/ai.d.ts +17 -2
  35. package/dist/esm/v3/ai.js +319 -110
  36. package/dist/esm/v3/ai.js.map +1 -1
  37. package/dist/esm/v3/aiAutoTelemetry.d.ts +2 -0
  38. package/dist/esm/v3/aiAutoTelemetry.js +78 -0
  39. package/dist/esm/v3/aiAutoTelemetry.js.map +1 -0
  40. package/dist/esm/v3/chat-client.js +6 -1
  41. package/dist/esm/v3/chat-client.js.map +1 -1
  42. package/dist/esm/v3/chat-react.js +10 -7
  43. package/dist/esm/v3/chat-react.js.map +1 -1
  44. package/dist/esm/v3/chat-server.d.ts +29 -6
  45. package/dist/esm/v3/chat-server.js +3 -1
  46. package/dist/esm/v3/chat-server.js.map +1 -1
  47. package/dist/esm/v3/chat.d.ts +11 -0
  48. package/dist/esm/v3/chat.js +95 -7
  49. package/dist/esm/v3/chat.js.map +1 -1
  50. package/dist/esm/v3/chat.test.js +53 -0
  51. package/dist/esm/v3/chat.test.js.map +1 -1
  52. package/dist/esm/v3/sessions.d.ts +8 -4
  53. package/dist/esm/v3/sessions.js +7 -3
  54. package/dist/esm/v3/sessions.js.map +1 -1
  55. package/dist/esm/v3/shared.js +18 -10
  56. package/dist/esm/v3/shared.js.map +1 -1
  57. package/dist/esm/v3/test/mock-chat-agent.d.ts +6 -0
  58. package/dist/esm/v3/test/mock-chat-agent.js +1 -0
  59. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  60. package/dist/esm/version.js +1 -1
  61. package/package.json +11 -5
package/dist/esm/v3/ai.js CHANGED
@@ -1,5 +1,7 @@
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";
2
- import { convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, tool as aiTool, zodSchema, } from "ai";
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
+ // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
+ // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
+ import { convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, tool as aiTool, zodSchema, } from "../imports/ai-runtime.js";
3
5
  import { trace } from "@opentelemetry/api";
4
6
  import { auth } from "./auth.js";
5
7
  import { locals } from "./locals.js";
@@ -16,6 +18,7 @@ import { runBashInSkill, readFileInSkill } from "./agentSkillsRuntime.js";
16
18
  import { markChatAgentRunForStreamsWarning } from "./streams.js";
17
19
  import { sessions, } from "./sessions.js";
18
20
  import { createTask } from "./shared.js";
21
+ import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
19
22
  import { resourceCatalog } from "@trigger.dev/core/v3";
20
23
  import { tracer } from "./tracer.js";
21
24
  const METADATA_KEY = "tool.execute.options";
@@ -65,51 +68,41 @@ const lastTurnCompleteSeqNumKey = locals.create("chat.lastTurnCompleteSeqNum");
65
68
  * the `.in` subscription so already-processed user messages don't get
66
69
  * replayed from S2.
67
70
  *
68
- * Implementation streams the SSE endpoint and listens for `turn-complete`
69
- * via the transport's `onControl` callback; the data-chunk for-await is
70
- * just there to drive the stream. The scan is O(1 turn) because
71
- * `session.out` is bounded to roughly one turn at steady state every
72
- * successful turn-complete is followed by an S2 trim back to the
73
- * 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.
74
77
  *
75
78
  * Returns `undefined` if no `turn-complete` carrying the header has been
76
79
  * written yet — first-turn-ever, first turn post-OOM-with-no-prior-runs,
77
- * or a `turn-complete` written before this header existed (cross-version
78
- * boot). Callers fall back to subscribing `.in` from seq 0 in that case;
79
- * the slim-wire merge handles any dedup against snapshot-restored
80
- * 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.
81
84
  * @internal
82
85
  */
83
86
  async function findLatestSessionInCursor(chatId) {
84
87
  const apiClient = apiClientManager.clientOrThrow();
88
+ const response = await apiClient.readSessionStreamRecords(chatId, "out");
85
89
  let latestCursor;
86
- const stream = await apiClient.subscribeToSessionStream(chatId, "out", {
87
- // 5s rather than 1s: S2 trim is eventually-consistent (10-60s
88
- // window), so a worker booting just after a trim could still see
89
- // pre-trim records and need a bit longer to drain them all before
90
- // the SSE long-poll closes. Without enough headroom the scan would
91
- // fall back to `undefined`, the `.in` cursor wouldn't be seeded,
92
- // and the next subscribe would replay messages already processed.
93
- timeoutInSeconds: 5,
94
- onControl: (event) => {
95
- if (event.subtype !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
96
- return;
97
- const raw = headerValue(event.headers, SESSION_IN_EVENT_ID_HEADER);
98
- if (!raw)
99
- return;
100
- const parsed = Number.parseInt(raw, 10);
101
- if (Number.isFinite(parsed))
102
- latestCursor = parsed;
103
- },
104
- });
105
- // Drain the stream so the underlying SSE reader runs to completion. We
106
- // don't accumulate chunks; `onControl` fires inline as turn-complete
107
- // records arrive.
108
- for await (const _ of stream) {
109
- // 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;
110
99
  }
111
100
  return latestCursor;
112
101
  }
102
+ /** Test-only entry point for the records-based cursor scan. @internal */
103
+ export async function __findLatestSessionInCursorForTests(chatId) {
104
+ return findLatestSessionInCursor(chatId);
105
+ }
113
106
  let readChatSnapshotImpl;
114
107
  export function __setReadChatSnapshotImplForTests(impl) {
115
108
  readChatSnapshotImpl = impl;
@@ -641,9 +634,14 @@ function createTaskToolExecuteHandler(task) {
641
634
  const toolMeta = {
642
635
  toolCallId: toolOpts?.toolCallId ?? "",
643
636
  };
644
- if (toolOpts?.experimental_context !== undefined) {
637
+ // v6 passes user context as `experimental_context`, v7 as `context`. Read
638
+ // whichever is set and stamp both so subtasks reading either name work.
639
+ const toolContext = toolOpts?.context ?? toolOpts?.experimental_context;
640
+ if (toolContext !== undefined) {
645
641
  try {
646
- toolMeta.experimental_context = JSON.parse(JSON.stringify(toolOpts.experimental_context));
642
+ const serialized = JSON.parse(JSON.stringify(toolContext));
643
+ toolMeta.experimental_context = serialized;
644
+ toolMeta.context = serialized;
647
645
  }
648
646
  catch {
649
647
  /* non-serializable */
@@ -972,8 +970,15 @@ const messagesInput = {
972
970
  on(handler) {
973
971
  return getChatSession().in.on((chunk) => {
974
972
  if (chunk.kind === "message") {
975
- 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;
976
980
  }
981
+ return undefined;
977
982
  });
978
983
  },
979
984
  once(options) {
@@ -1067,8 +1072,13 @@ const stopInput = {
1067
1072
  on(handler) {
1068
1073
  return getChatSession().in.on((chunk) => {
1069
1074
  if (chunk.kind === "stop") {
1070
- 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;
1071
1080
  }
1081
+ return undefined;
1072
1082
  });
1073
1083
  },
1074
1084
  once(options) {
@@ -1222,6 +1232,9 @@ const chatHandoverIsFinalKey = locals.create("chat.handoverIsFinal");
1222
1232
  * `tool-approval-response` rows are AI-SDK-internal and don't need a
1223
1233
  * UIMessage representation. We map:
1224
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)
1225
1238
  * - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
1226
1239
  * state: "input-available", input }`
1227
1240
  * - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1238,6 +1251,14 @@ function synthesizeHandoverUIMessage(partial, messageId) {
1238
1251
  if (part.type === "text" && typeof part.text === "string") {
1239
1252
  parts.push({ type: "text", text: part.text });
1240
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
+ }
1241
1262
  else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
1242
1263
  parts.push({
1243
1264
  type: `tool-${part.toolName}`,
@@ -2582,6 +2603,11 @@ function chatCustomAgent(options) {
2582
2603
  // No client-side upsert needed.
2583
2604
  locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
2584
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 });
2585
2611
  markChatAgentRunForStreamsWarning();
2586
2612
  taskContext.setConversationId(payload.chatId);
2587
2613
  stampConversationIdOnActiveSpan(payload.chatId);
@@ -2615,6 +2641,11 @@ function chatAgent(options) {
2615
2641
  agentConfig: { type: "ai-sdk-chat" },
2616
2642
  run: async (payload, { signal: runSignal, ctx }) => {
2617
2643
  locals.set(chatAgentRunContextKey, ctx);
2644
+ // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
2645
+ // so `experimental_telemetry` spans flow into the run trace. Awaited here
2646
+ // at run boot — before any `streamText` — and a no-op on v5/v6 or when the
2647
+ // optional `@ai-sdk/otel` peer isn't installed. See ./aiAutoTelemetry.ts.
2648
+ await ensureAiSdkTelemetry();
2618
2649
  // Bind the run to its backing Session so every module-level helper
2619
2650
  // (chat.stream, chat.messages, chat.stopSignal) resolves to this
2620
2651
  // chat's `.in` / `.out` channels.
@@ -2702,6 +2733,11 @@ function chatAgent(options) {
2702
2733
  // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
2703
2734
  const bootInjectedQueue = [];
2704
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;
2705
2741
  if (!hydrateMessages && couldHavePriorState) {
2706
2742
  // Single parent span for the whole boot read phase — snapshot
2707
2743
  // read, session.out replay, session.in replay. Per-phase timing
@@ -2737,23 +2773,28 @@ function chatAgent(options) {
2737
2773
  slot.value = seeded;
2738
2774
  }
2739
2775
  }
2740
- // session.out replay
2741
- const replayOutStart = Date.now();
2742
- try {
2743
- const replayResult = await replaySessionOutTail(sessionIdForSnapshot, { lastEventId: bootSnapshot?.lastOutEventId });
2744
- replayedSettled = replayResult.settled;
2745
- replayedPartial = replayResult.partial;
2746
- replayedPartialRaw = replayResult.partialRaw;
2747
- }
2748
- catch (error) {
2749
- logger.warn("chat.agent: session.out replay failed; using snapshot only", {
2750
- error: error instanceof Error ? error.message : String(error),
2751
- sessionId: sessionIdForSnapshot,
2752
- });
2753
- }
2754
- bootSpan.setAttribute("chat.boot.replay.out.durationMs", Date.now() - replayOutStart);
2755
- bootSpan.setAttribute("chat.boot.replay.out.settledCount", replayedSettled.length);
2756
- 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
+ };
2757
2798
  // session.in tail read
2758
2799
  //
2759
2800
  // session.in carries the user-side of the conversation
@@ -2764,20 +2805,43 @@ function chatAgent(options) {
2764
2805
  // visible via the live SSE subscription — by which point they
2765
2806
  // would arrive AFTER the partial-assistant orphan and look like
2766
2807
  // brand-new turns to the model, producing inverted chains.
2767
- const replayInStart = Date.now();
2768
- const lastInEventId = await findLatestSessionInCursor(payload.chatId)
2769
- .then((cursor) => (cursor !== undefined ? String(cursor) : undefined))
2770
- .catch(() => undefined);
2771
- try {
2772
- replayedInTail = await replaySessionInTail(payload.chatId, {
2773
- lastEventId: lastInEventId,
2774
- });
2775
- }
2776
- catch (error) {
2777
- logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
2778
- }
2779
- bootSpan.setAttribute("chat.boot.replay.in.durationMs", Date.now() - replayInStart);
2780
- 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()]);
2781
2845
  }, {
2782
2846
  attributes: {
2783
2847
  [SemanticInternalAttributes.STYLE_ICON]: "tabler-rotate-clockwise",
@@ -2818,7 +2882,12 @@ function chatAgent(options) {
2818
2882
  bootSnapshot !== undefined;
2819
2883
  if (needsResumeCursor) {
2820
2884
  try {
2821
- 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);
2822
2891
  if (cursor !== undefined) {
2823
2892
  sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
2824
2893
  sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
@@ -3582,6 +3651,18 @@ function chatAgent(options) {
3582
3651
  // therefore a delta merge, not a full-history reset.
3583
3652
  if (currentWirePayload.trigger !== "action") {
3584
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
+ }
3585
3666
  // Validate/transform UIMessages before conversion — catches malformed
3586
3667
  // messages from storage or untrusted input before they reach the model.
3587
3668
  // Slim wire: triggers like `regenerate-message` carry no incoming
@@ -3760,40 +3841,47 @@ function chatAgent(options) {
3760
3841
  // `preload` / `close` / `handover-prepare` and submits
3761
3842
  // with no incoming message fall through with the boot-
3762
3843
  // seeded accumulator unchanged.
3763
- if (turn === 0) {
3764
- // Head-start handover splice (turn 0 only): the
3765
- // `chat.handover` route handler signalled a mid-turn
3766
- // handover, so splice its partial assistant response
3767
- // (text + pending tool-calls + the synthesized
3768
- // tool-approval round) onto the accumulator.
3769
- // `streamText` then hits AI SDK's initial-tool-
3770
- // execution branch, runs the agent-side tool executes,
3771
- // and resumes from step 2 — skipping the first model
3772
- // call (already done by the handler).
3773
- //
3774
- // We also synthesize a UIMessage form of the partial
3775
- // assistant and push it to `accumulatedUIMessages` so
3776
- // AI SDK's `processUIMessageStream` (invoked when the
3777
- // run loop calls `runResult.toUIMessageStream({
3778
- // onFinish })`) can initialize `state.message` from
3779
- // the trailing assistant in `originalMessages`. Without
3780
- // that, the `tool-output-available` chunks emitted by
3781
- // the initial-tool-execution branch can't find their
3782
- // matching tool-call in state and AI SDK throws
3783
- // `UIMessageStreamError: No tool invocation found`.
3784
- const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
3785
- 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) {
3786
3875
  accumulatedMessages.push(...pendingHandoverPartial);
3787
- const handoverMessageId = locals.get(chatHandoverMessageIdKey);
3788
3876
  const partialUI = synthesizeHandoverUIMessage(pendingHandoverPartial, handoverMessageId);
3789
3877
  if (partialUI) {
3790
3878
  accumulatedUIMessages.push(partialUI);
3791
3879
  }
3792
- locals.set(chatHandoverPartialKey, []); // consume once
3793
3880
  }
3881
+ locals.set(chatHandoverPartialKey, []); // consume once
3794
3882
  }
3795
- locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3796
3883
  }
3884
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
3797
3885
  } // end if (trigger !== "action")
3798
3886
  // ── Action result handling ──────────────────────────────
3799
3887
  // For action turns, skip the turn machinery entirely.
@@ -4490,11 +4578,15 @@ function chatAgent(options) {
4490
4578
  if (!hydrateMessages) {
4491
4579
  try {
4492
4580
  await tracer.startActiveSpan("snapshot.write", async () => {
4581
+ const snapshotInCursor = getChatSession().in.lastDispatchedSeqNum();
4493
4582
  await writeChatSnapshot(sessionIdForSnapshot, {
4494
4583
  version: 1,
4495
4584
  savedAt: Date.now(),
4496
4585
  messages: accumulatedUIMessages,
4497
4586
  lastOutEventId: turnCompleteResult?.lastEventId,
4587
+ lastInEventId: snapshotInCursor !== undefined
4588
+ ? String(snapshotInCursor)
4589
+ : undefined,
4498
4590
  });
4499
4591
  }, {
4500
4592
  attributes: {
@@ -4631,17 +4723,100 @@ function chatAgent(options) {
4631
4723
  if (turnError instanceof OutOfMemoryError) {
4632
4724
  throw turnError;
4633
4725
  }
4726
+ let errorTurnCompleteResult;
4634
4727
  try {
4635
4728
  await withChatWriter(async (writer) => {
4636
4729
  const errorText = turnError instanceof Error ? turnError.message : "An unexpected error occurred";
4637
4730
  writer.write({ type: "error", errorText });
4638
4731
  });
4639
4732
  // Signal turn complete so the client knows this turn is done
4640
- await writeTurnCompleteChunk(currentWirePayload.chatId);
4733
+ errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
4641
4734
  }
4642
4735
  catch {
4643
4736
  // Best-effort — if stream write fails, let the run continue anyway
4644
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
+ }
4645
4820
  // chat.requestUpgrade() / chat.endRun() — exit after error turn too
4646
4821
  if (locals.get(chatUpgradeRequestedKey) ||
4647
4822
  locals.get(chatEndRunRequestedKey)) {
@@ -5507,18 +5682,32 @@ function createChatSession(payload, options) {
5507
5682
  return {
5508
5683
  async next() {
5509
5684
  turn++;
5510
- // First turn: handle preload wait for the first real message
5511
- 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)) {
5512
5692
  const result = await messagesInput.waitWithIdleTimeout({
5513
5693
  idleTimeoutInSeconds: sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30,
5514
5694
  timeout,
5515
- spanName: "waiting for first message",
5695
+ spanName: currentPayload.trigger === "preload"
5696
+ ? "waiting for first message"
5697
+ : "waiting for first message (continuation)",
5516
5698
  });
5517
5699
  if (!result.ok || runSignal.aborted) {
5518
5700
  stop.cleanup();
5519
5701
  return { done: true, value: undefined };
5520
5702
  }
5703
+ const continuationBoot = isMessagelessContinuationBoot;
5521
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
+ }
5522
5711
  }
5523
5712
  // Subsequent turns: wait for the next message
5524
5713
  if (turn > 0) {
@@ -5671,14 +5860,22 @@ function createChatSession(payload, options) {
5671
5860
  locals.set(chatResponsePartsKey, []);
5672
5861
  }
5673
5862
  }
5674
- // 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).
5675
5867
  let turnUsage;
5676
5868
  if (typeof source.totalUsage?.then === "function") {
5677
5869
  try {
5678
- const usage = await source.totalUsage;
5679
- turnUsage = usage;
5680
- previousTurnUsage = usage;
5681
- 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
+ }
5682
5879
  }
5683
5880
  catch {
5684
5881
  /* non-fatal */
@@ -6021,7 +6218,8 @@ function createChatStartSessionAction(taskId, options) {
6021
6218
  // run-list filter by chat works without the customer having to wire it
6022
6219
  // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6023
6220
  const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6024
- 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);
6025
6223
  const clientDataMetadata = params.clientData !== undefined ? { metadata: params.clientData } : {};
6026
6224
  const triggerConfig = {
6027
6225
  basePayload: {
@@ -6319,8 +6517,19 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
6319
6517
  // 2. Trim back to the previous turn-complete, if we have one. Skipping on
6320
6518
  // first-turn-ever (or first turn post-OOM without a snapshot seed) is
6321
6519
  // fine — the chain catches up next turn.
6322
- const slot = locals.get(lastTurnCompleteSeqNumKey);
6323
- 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;
6324
6533
  if (slot && prev !== undefined) {
6325
6534
  try {
6326
6535
  await session.out.trimTo(prev);