@trigger.dev/sdk 0.0.0-prerelease-20260306173130 → 0.0.0-prerelease-20260310150337

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.
@@ -14,6 +14,7 @@ const chat_constants_js_1 = require("./chat-constants.js");
14
14
  Object.defineProperty(exports, "CHAT_MESSAGES_STREAM_ID", { enumerable: true, get: function () { return chat_constants_js_1.CHAT_MESSAGES_STREAM_ID; } });
15
15
  Object.defineProperty(exports, "CHAT_STOP_STREAM_ID", { enumerable: true, get: function () { return chat_constants_js_1.CHAT_STOP_STREAM_ID; } });
16
16
  const METADATA_KEY = "tool.execute.options";
17
+ const chatTurnContextKey = locals_js_1.locals.create("chat.turnContext");
17
18
  function toolFromTask(task, options) {
18
19
  if (("schema" in task && !task.schema) || ("jsonSchema" in task && !task.jsonSchema)) {
19
20
  throw new Error("Cannot convert this task to to a tool because the task has no schema. Make sure to either use schemaTask or a task with an input jsonSchema.");
@@ -22,12 +23,43 @@ function toolFromTask(task, options) {
22
23
  description: task.description,
23
24
  inputSchema: convertTaskSchemaToToolParameters(task),
24
25
  execute: async (input, options) => {
25
- const serializedOptions = options ? JSON.parse(JSON.stringify(options)) : undefined;
26
+ // Build tool metadata — skip messages (can be large) and abortSignal (non-serializable)
27
+ const toolMeta = {
28
+ toolCallId: options?.toolCallId ?? "",
29
+ };
30
+ if (options?.experimental_context !== undefined) {
31
+ try {
32
+ toolMeta.experimental_context = JSON.parse(JSON.stringify(options.experimental_context));
33
+ }
34
+ catch {
35
+ // Non-serializable context — skip
36
+ }
37
+ }
38
+ // Auto-detect chat context from the parent turn
39
+ const chatCtx = locals_js_1.locals.get(chatTurnContextKey);
40
+ if (chatCtx) {
41
+ toolMeta.chatId = chatCtx.chatId;
42
+ toolMeta.turn = chatCtx.turn;
43
+ toolMeta.continuation = chatCtx.continuation;
44
+ toolMeta.clientData = chatCtx.clientData;
45
+ }
46
+ // Serialize initialized chat.local values for subtask hydration
47
+ const chatLocals = {};
48
+ for (const entry of chatLocalRegistry) {
49
+ const value = locals_js_1.locals.get(entry.key);
50
+ if (value !== undefined) {
51
+ chatLocals[entry.id] = value;
52
+ }
53
+ }
54
+ if (Object.keys(chatLocals).length > 0) {
55
+ toolMeta.chatLocals = chatLocals;
56
+ }
26
57
  return await task
27
58
  .triggerAndWait(input, {
28
59
  metadata: {
29
- [METADATA_KEY]: serializedOptions,
60
+ [METADATA_KEY]: toolMeta,
30
61
  },
62
+ tags: options?.toolCallId ? [`toolCallId:${options.toolCallId}`] : undefined,
31
63
  })
32
64
  .unwrap();
33
65
  },
@@ -42,6 +74,53 @@ function getToolOptionsFromMetadata() {
42
74
  }
43
75
  return tool;
44
76
  }
77
+ /**
78
+ * Get the current tool call ID from inside a subtask invoked via `ai.tool()`.
79
+ * Returns `undefined` if not running as a tool subtask.
80
+ */
81
+ function getToolCallId() {
82
+ return getToolOptionsFromMetadata()?.toolCallId;
83
+ }
84
+ /**
85
+ * Get the chat context from inside a subtask invoked via `ai.tool()` within a `chat.task`.
86
+ * Pass `typeof yourChatTask` as the type parameter to get typed `clientData`.
87
+ * Returns `undefined` if the parent is not a chat task.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const ctx = ai.chatContext<typeof myChat>();
92
+ * // ctx?.clientData is typed based on myChat's clientDataSchema
93
+ * ```
94
+ */
95
+ function getToolChatContext() {
96
+ const opts = getToolOptionsFromMetadata();
97
+ if (!opts?.chatId)
98
+ return undefined;
99
+ return {
100
+ chatId: opts.chatId,
101
+ turn: opts.turn ?? 0,
102
+ continuation: opts.continuation ?? false,
103
+ clientData: opts.clientData,
104
+ };
105
+ }
106
+ /**
107
+ * Get the chat context from inside a subtask, throwing if not in a chat context.
108
+ * Pass `typeof yourChatTask` as the type parameter to get typed `clientData`.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const ctx = ai.chatContextOrThrow<typeof myChat>();
113
+ * // ctx.chatId, ctx.clientData are guaranteed non-null
114
+ * ```
115
+ */
116
+ function getToolChatContextOrThrow() {
117
+ const ctx = getToolChatContext();
118
+ if (!ctx) {
119
+ throw new Error("ai.chatContextOrThrow() called outside of a chat.task context. " +
120
+ "This helper can only be used inside a subtask invoked via ai.tool() from a chat.task.");
121
+ }
122
+ return ctx;
123
+ }
45
124
  function convertTaskSchemaToToolParameters(task) {
46
125
  if ("schema" in task) {
47
126
  // If TaskSchema is ArkTypeEsque, use ai.jsonSchema to convert it to a Schema
@@ -61,6 +140,12 @@ function convertTaskSchemaToToolParameters(task) {
61
140
  exports.ai = {
62
141
  tool: toolFromTask,
63
142
  currentToolOptions: getToolOptionsFromMetadata,
143
+ /** Get the tool call ID from inside a subtask invoked via `ai.tool()`. */
144
+ toolCallId: getToolCallId,
145
+ /** Get chat context (chatId, turn, clientData, etc.) from inside a subtask of a `chat.task`. Returns undefined if not in a chat context. */
146
+ chatContext: getToolChatContext,
147
+ /** Get chat context or throw if not in a chat context. Pass `typeof yourChatTask` for typed clientData. */
148
+ chatContextOrThrow: getToolChatContextOrThrow,
64
149
  };
65
150
  /**
66
151
  * Creates a public access token for a chat task.
@@ -91,15 +176,48 @@ function createChatAccessToken(taskId) {
91
176
  * use this key by default.
92
177
  */
93
178
  exports.CHAT_STREAM_KEY = chat_constants_js_1.CHAT_STREAM_KEY;
179
+ /**
180
+ * Typed chat output stream. Provides `.writer()`, `.pipe()`, `.append()`,
181
+ * and `.read()` methods pre-bound to the chat stream key and typed to `UIMessageChunk`.
182
+ *
183
+ * Use from within a `chat.task` run to write custom chunks:
184
+ * ```ts
185
+ * const { waitUntilComplete } = chat.stream.writer({
186
+ * execute: ({ write }) => {
187
+ * write({ type: "text-start", id: "status-1" });
188
+ * write({ type: "text-delta", id: "status-1", delta: "Processing..." });
189
+ * write({ type: "text-end", id: "status-1" });
190
+ * },
191
+ * });
192
+ * await waitUntilComplete();
193
+ * ```
194
+ *
195
+ * Use from a subtask to stream back to the parent chat:
196
+ * ```ts
197
+ * chat.stream.pipe(myStream, { target: "root" });
198
+ * ```
199
+ */
200
+ const chatStream = streams_js_1.streams.define({ id: chat_constants_js_1.CHAT_STREAM_KEY });
94
201
  // Input streams for bidirectional chat communication
95
202
  const messagesInput = streams_js_1.streams.input({ id: chat_constants_js_1.CHAT_MESSAGES_STREAM_ID });
96
203
  const stopInput = streams_js_1.streams.input({ id: chat_constants_js_1.CHAT_STOP_STREAM_ID });
204
+ /**
205
+ * Per-turn deferred promises. Registered via `chat.defer()`, awaited
206
+ * before `onTurnComplete` fires. Reset each turn.
207
+ * @internal
208
+ */
209
+ const chatDeferKey = locals_js_1.locals.create("chat.defer");
97
210
  /**
98
211
  * Run-scoped pipe counter. Stored in locals so concurrent runs in the
99
212
  * same worker don't share state.
100
213
  * @internal
101
214
  */
102
215
  const chatPipeCountKey = locals_js_1.locals.create("chat.pipeCount");
216
+ const chatStopControllerKey = locals_js_1.locals.create("chat.stopController");
217
+ /** Static (task-level) UIMessageStream options, set once during chatTask setup. @internal */
218
+ const chatUIStreamStaticKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.static");
219
+ /** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
220
+ const chatUIStreamPerTurnKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.perTurn");
103
221
  function isUIMessageStreamable(value) {
104
222
  return (typeof value === "object" &&
105
223
  value !== null &&
@@ -206,7 +324,7 @@ async function pipeChat(source, options) {
206
324
  * ```
207
325
  */
208
326
  function chatTask(options) {
209
- const { run: userRun, clientDataSchema, onChatStart, onTurnStart, onTurnComplete, maxTurns = 100, turnTimeout = "1h", warmTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", ...restOptions } = options;
327
+ const { run: userRun, clientDataSchema, onPreload, onChatStart, onTurnStart, onTurnComplete, maxTurns = 100, turnTimeout = "1h", warmTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadWarmTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, ...restOptions } = options;
210
328
  const parseClientData = clientDataSchema
211
329
  ? (0, v3_1.getSchemaParseFn)(clientDataSchema)
212
330
  : undefined;
@@ -218,7 +336,14 @@ function chatTask(options) {
218
336
  if (activeSpan) {
219
337
  activeSpan.setAttribute("gen_ai.conversation.id", payload.chatId);
220
338
  }
339
+ // Store static UIMessageStream options in locals so resolveUIMessageStreamOptions() can read them
340
+ if (uiMessageStreamOptions) {
341
+ locals_js_1.locals.set(chatUIStreamStaticKey, uiMessageStreamOptions);
342
+ }
221
343
  let currentWirePayload = payload;
344
+ const continuation = payload.continuation ?? false;
345
+ const previousRunId = payload.previousRunId;
346
+ const preloaded = payload.trigger === "preload";
222
347
  // Accumulated model messages across turns. Turn 1 initialises from the
223
348
  // full history the frontend sends; subsequent turns append only the new
224
349
  // user message(s) and the captured assistant response.
@@ -234,6 +359,67 @@ function chatTask(options) {
234
359
  currentStopController?.abort(data?.message || "stopped");
235
360
  });
236
361
  try {
362
+ // Handle preloaded runs — fire onPreload, then wait for the first real message
363
+ if (preloaded) {
364
+ if (activeSpan) {
365
+ activeSpan.setAttribute("chat.preloaded", true);
366
+ }
367
+ const currentRunId = v3_1.taskContext.ctx?.run.id ?? "";
368
+ let preloadAccessToken = "";
369
+ if (currentRunId) {
370
+ try {
371
+ preloadAccessToken = await auth_js_1.auth.createPublicToken({
372
+ scopes: {
373
+ read: { runs: currentRunId },
374
+ write: { inputStreams: currentRunId },
375
+ },
376
+ expirationTime: chatAccessTokenTTL,
377
+ });
378
+ }
379
+ catch {
380
+ // Token creation failed
381
+ }
382
+ }
383
+ // Parse client data for the preload hook
384
+ const preloadClientData = (parseClientData
385
+ ? await parseClientData(payload.metadata)
386
+ : payload.metadata);
387
+ // Fire onPreload hook
388
+ if (onPreload) {
389
+ await tracer_js_1.tracer.startActiveSpan("onPreload()", async () => {
390
+ await onPreload({
391
+ chatId: payload.chatId,
392
+ runId: currentRunId,
393
+ chatAccessToken: preloadAccessToken,
394
+ clientData: preloadClientData,
395
+ });
396
+ }, {
397
+ attributes: {
398
+ [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
399
+ [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
400
+ "chat.id": payload.chatId,
401
+ "chat.preloaded": true,
402
+ },
403
+ });
404
+ }
405
+ // Wait for the first real message — use preload-specific timeouts if configured
406
+ const effectivePreloadWarmTimeout = payload.warmTimeoutInSeconds
407
+ ?? preloadWarmTimeoutInSeconds
408
+ ?? warmTimeoutInSeconds;
409
+ const effectivePreloadTimeout = metadata_js_1.metadata.get(TURN_TIMEOUT_METADATA_KEY)
410
+ ?? preloadTimeout
411
+ ?? turnTimeout;
412
+ const preloadResult = await messagesInput.waitWithWarmup({
413
+ warmTimeoutInSeconds: effectivePreloadWarmTimeout,
414
+ timeout: effectivePreloadTimeout,
415
+ spanName: "waiting for first message",
416
+ });
417
+ if (!preloadResult.ok) {
418
+ return; // Timed out waiting for first message — end run
419
+ }
420
+ let firstMessage = preloadResult.output;
421
+ currentWirePayload = firstMessage;
422
+ }
237
423
  for (let turn = 0; turn < maxTurns; turn++) {
238
424
  // Extract turn-level context before entering the span
239
425
  const { metadata: wireMetadata, messages: uiMessages, ...restWire } = currentWirePayload;
@@ -266,9 +452,18 @@ function chatTask(options) {
266
452
  }
267
453
  const turnResult = await tracer_js_1.tracer.startActiveSpan(`chat turn ${turn + 1}`, async () => {
268
454
  locals_js_1.locals.set(chatPipeCountKey, 0);
455
+ locals_js_1.locals.set(chatDeferKey, new Set());
456
+ // Store chat context for auto-detection by ai.tool subtasks
457
+ locals_js_1.locals.set(chatTurnContextKey, {
458
+ chatId: currentWirePayload.chatId,
459
+ turn,
460
+ continuation,
461
+ clientData,
462
+ });
269
463
  // Per-turn stop controller (reset each turn)
270
464
  const stopController = new AbortController();
271
465
  currentStopController = stopController;
466
+ locals_js_1.locals.set(chatStopControllerKey, stopController);
272
467
  // Three signals for the user's run function
273
468
  const stopSignal = stopController.signal;
274
469
  const cancelSignal = runSignal;
@@ -278,20 +473,26 @@ function chatTask(options) {
278
473
  const msgSub = messagesInput.on((msg) => {
279
474
  pendingMessages.push(msg);
280
475
  });
476
+ // Clean up any incomplete tool parts in the incoming history.
477
+ // When a previous run was stopped mid-tool-call, the frontend's
478
+ // useChat state may still contain assistant messages with tool parts
479
+ // in partial/input-available state. These cause API errors (e.g.
480
+ // Anthropic requires every tool_use to have a matching tool_result).
481
+ const cleanedUIMessages = uiMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
281
482
  // Convert the incoming UIMessages to model messages and update the accumulator.
282
483
  // Turn 1: full history from the frontend → replaces the accumulator.
283
484
  // Turn 2+: only the new message(s) → appended to the accumulator.
284
- const incomingModelMessages = await (0, ai_1.convertToModelMessages)(uiMessages);
485
+ const incomingModelMessages = await (0, ai_1.convertToModelMessages)(cleanedUIMessages);
285
486
  // Track new messages for this turn (user input + assistant response).
286
487
  const turnNewModelMessages = [];
287
488
  const turnNewUIMessages = [];
288
489
  if (turn === 0) {
289
490
  accumulatedMessages = incomingModelMessages;
290
- accumulatedUIMessages = [...uiMessages];
491
+ accumulatedUIMessages = [...cleanedUIMessages];
291
492
  // On first turn, the "new" messages are just the last user message
292
493
  // (the rest is history). We'll add the response after streaming.
293
- if (uiMessages.length > 0) {
294
- turnNewUIMessages.push(uiMessages[uiMessages.length - 1]);
494
+ if (cleanedUIMessages.length > 0) {
495
+ turnNewUIMessages.push(cleanedUIMessages[cleanedUIMessages.length - 1]);
295
496
  const lastModel = incomingModelMessages[incomingModelMessages.length - 1];
296
497
  if (lastModel)
297
498
  turnNewModelMessages.push(lastModel);
@@ -301,15 +502,15 @@ function chatTask(options) {
301
502
  // Regenerate: frontend sent full history with last assistant message
302
503
  // removed. Reset the accumulator to match.
303
504
  accumulatedMessages = incomingModelMessages;
304
- accumulatedUIMessages = [...uiMessages];
505
+ accumulatedUIMessages = [...cleanedUIMessages];
305
506
  // No new user messages for regenerate — just the response (added below)
306
507
  }
307
508
  else {
308
509
  // Submit: frontend sent only the new user message(s). Append to accumulator.
309
510
  accumulatedMessages.push(...incomingModelMessages);
310
- accumulatedUIMessages.push(...uiMessages);
511
+ accumulatedUIMessages.push(...cleanedUIMessages);
311
512
  turnNewModelMessages.push(...incomingModelMessages);
312
- turnNewUIMessages.push(...uiMessages);
513
+ turnNewUIMessages.push(...cleanedUIMessages);
313
514
  }
314
515
  // Mint a scoped public access token once per turn, reused for
315
516
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
@@ -338,11 +539,19 @@ function chatTask(options) {
338
539
  clientData,
339
540
  runId: currentRunId,
340
541
  chatAccessToken: turnAccessToken,
542
+ continuation,
543
+ previousRunId,
544
+ preloaded,
341
545
  });
342
546
  }, {
343
547
  attributes: {
344
548
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
345
549
  [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
550
+ "chat.id": currentWirePayload.chatId,
551
+ "chat.messages.count": accumulatedMessages.length,
552
+ "chat.continuation": continuation,
553
+ "chat.preloaded": preloaded,
554
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
346
555
  },
347
556
  });
348
557
  }
@@ -358,21 +567,42 @@ function chatTask(options) {
358
567
  runId: currentRunId,
359
568
  chatAccessToken: turnAccessToken,
360
569
  clientData,
570
+ continuation,
571
+ previousRunId,
572
+ preloaded,
361
573
  });
362
574
  }, {
363
575
  attributes: {
364
576
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
365
577
  [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
578
+ "chat.id": currentWirePayload.chatId,
579
+ "chat.turn": turn + 1,
580
+ "chat.messages.count": accumulatedMessages.length,
581
+ "chat.trigger": currentWirePayload.trigger,
582
+ "chat.continuation": continuation,
583
+ "chat.preloaded": preloaded,
584
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
366
585
  },
367
586
  });
368
587
  }
369
588
  // Captured by the onFinish callback below — works even on abort/stop.
370
589
  let capturedResponseMessage;
590
+ // Promise that resolves when the AI SDK's onFinish fires.
591
+ // On abort, the stream's cancel() handler calls onFinish
592
+ // asynchronously AFTER pipeChat resolves, so we must await
593
+ // this to avoid a race where we check capturedResponseMessage
594
+ // before it's been set.
595
+ let resolveOnFinish;
596
+ const onFinishPromise = new Promise((r) => { resolveOnFinish = r; });
597
+ let onFinishAttached = false;
371
598
  try {
372
599
  const result = await userRun({
373
600
  ...restWire,
374
601
  messages: accumulatedMessages,
375
602
  clientData,
603
+ continuation,
604
+ previousRunId,
605
+ preloaded,
376
606
  signal: combinedSignal,
377
607
  cancelSignal,
378
608
  stopSignal,
@@ -381,9 +611,12 @@ function chatTask(options) {
381
611
  // but only if pipeChat() wasn't already called manually during this turn.
382
612
  // We call toUIMessageStream ourselves to attach onFinish for response capture.
383
613
  if ((locals_js_1.locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(result)) {
614
+ onFinishAttached = true;
384
615
  const uiStream = result.toUIMessageStream({
616
+ ...resolveUIMessageStreamOptions(),
385
617
  onFinish: ({ responseMessage }) => {
386
618
  capturedResponseMessage = responseMessage;
619
+ resolveOnFinish();
387
620
  },
388
621
  });
389
622
  await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" });
@@ -404,10 +637,24 @@ function chatTask(options) {
404
637
  finally {
405
638
  msgSub.off();
406
639
  }
640
+ // Wait for onFinish to fire — on abort this may resolve slightly
641
+ // after pipeChat, since the stream's cancel() handler is async.
642
+ if (onFinishAttached) {
643
+ await onFinishPromise;
644
+ }
645
+ // Determine if the user stopped generation this turn (not a full run cancel).
646
+ const wasStopped = stopController.signal.aborted && !runSignal.aborted;
407
647
  // Append the assistant's response (partial or complete) to the accumulator.
408
648
  // The onFinish callback fires even on abort/stop, so partial responses
409
649
  // from stopped generation are captured correctly.
650
+ let rawResponseMessage;
410
651
  if (capturedResponseMessage) {
652
+ // Keep the raw message before cleanup for users who want custom handling
653
+ rawResponseMessage = capturedResponseMessage;
654
+ // Clean up aborted parts (streaming tool calls, reasoning) when stopped
655
+ if (wasStopped) {
656
+ capturedResponseMessage = cleanupAbortedParts(capturedResponseMessage);
657
+ }
411
658
  // Ensure the response message has an ID (the stream's onFinish
412
659
  // may produce a message with an empty ID since IDs are normally
413
660
  // assigned by the frontend's useChat).
@@ -436,6 +683,15 @@ function chatTask(options) {
436
683
  // Write turn-complete control chunk so frontend closes its stream.
437
684
  // Capture the lastEventId from the stream writer for resume support.
438
685
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
686
+ // Await deferred background work (e.g. DB writes from onTurnStart)
687
+ // before firing onTurnComplete so hooks can rely on the work being done.
688
+ const deferredWork = locals_js_1.locals.get(chatDeferKey);
689
+ if (deferredWork && deferredWork.size > 0) {
690
+ await Promise.race([
691
+ Promise.allSettled(deferredWork),
692
+ new Promise((r) => setTimeout(r, 5_000)),
693
+ ]);
694
+ }
439
695
  // Fire onTurnComplete after response capture
440
696
  if (onTurnComplete) {
441
697
  await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -446,16 +702,30 @@ function chatTask(options) {
446
702
  newMessages: turnNewModelMessages,
447
703
  newUIMessages: turnNewUIMessages,
448
704
  responseMessage: capturedResponseMessage,
705
+ rawResponseMessage,
449
706
  turn,
450
707
  runId: currentRunId,
451
708
  chatAccessToken: turnAccessToken,
452
709
  lastEventId: turnCompleteResult.lastEventId,
453
710
  clientData,
711
+ stopped: wasStopped,
712
+ continuation,
713
+ previousRunId,
714
+ preloaded,
454
715
  });
455
716
  }, {
456
717
  attributes: {
457
718
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
458
719
  [v3_1.SemanticInternalAttributes.COLLAPSED]: true,
720
+ "chat.id": currentWirePayload.chatId,
721
+ "chat.turn": turn + 1,
722
+ "chat.stopped": wasStopped,
723
+ "chat.continuation": continuation,
724
+ "chat.preloaded": preloaded,
725
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
726
+ "chat.messages.count": accumulatedMessages.length,
727
+ "chat.response.parts.count": capturedResponseMessage?.parts?.length ?? 0,
728
+ "chat.new_messages.count": turnNewUIMessages.length,
459
729
  },
460
730
  });
461
731
  }
@@ -464,28 +734,15 @@ function chatTask(options) {
464
734
  currentWirePayload = pendingMessages[0];
465
735
  return "continue";
466
736
  }
467
- // Phase 1: Keep the run warm for quick response to the next message.
468
- // The run stays active (using compute) during this window.
737
+ // Wait for the next message — stay warm briefly, then suspend
469
738
  const effectiveWarmTimeout = metadata_js_1.metadata.get(WARM_TIMEOUT_METADATA_KEY) ?? warmTimeoutInSeconds;
470
- if (effectiveWarmTimeout > 0) {
471
- const warm = await messagesInput.once({
472
- timeoutMs: effectiveWarmTimeout * 1000,
473
- spanName: "waiting (warm)",
474
- });
475
- if (warm.ok) {
476
- // Message arrived while warm — respond instantly
477
- currentWirePayload = warm.output;
478
- return "continue";
479
- }
480
- }
481
- // Phase 2: Suspend the task (frees compute) until the next message arrives
482
739
  const effectiveTurnTimeout = metadata_js_1.metadata.get(TURN_TIMEOUT_METADATA_KEY) ?? turnTimeout;
483
- const next = await messagesInput.wait({
740
+ const next = await messagesInput.waitWithWarmup({
741
+ warmTimeoutInSeconds: effectiveWarmTimeout,
484
742
  timeout: effectiveTurnTimeout,
485
- spanName: "waiting (suspended)",
743
+ spanName: "waiting for next message",
486
744
  });
487
745
  if (!next.ok) {
488
- // Timed out waiting for the next message — end the conversation
489
746
  return "exit";
490
747
  }
491
748
  currentWirePayload = next.output;
@@ -589,6 +846,424 @@ function setTurnTimeoutInSeconds(seconds) {
589
846
  function setWarmTimeoutInSeconds(seconds) {
590
847
  metadata_js_1.metadata.set(WARM_TIMEOUT_METADATA_KEY, seconds);
591
848
  }
849
+ /**
850
+ * Override the `toUIMessageStream()` options for the current turn.
851
+ *
852
+ * These options control how the `StreamTextResult` is converted to a
853
+ * `UIMessageChunk` stream — error handling, reasoning/source visibility,
854
+ * message metadata, etc.
855
+ *
856
+ * Per-turn options are merged on top of the static `uiMessageStreamOptions`
857
+ * set on `chat.task()`. Per-turn values win on conflicts.
858
+ *
859
+ * @example
860
+ * ```ts
861
+ * run: async ({ messages, signal }) => {
862
+ * chat.setUIMessageStreamOptions({
863
+ * sendReasoning: true,
864
+ * onError: (error) => error instanceof Error ? error.message : "An error occurred.",
865
+ * });
866
+ * return streamText({ model, messages, abortSignal: signal });
867
+ * }
868
+ * ```
869
+ */
870
+ function setUIMessageStreamOptions(options) {
871
+ locals_js_1.locals.set(chatUIStreamPerTurnKey, options);
872
+ }
873
+ /**
874
+ * Resolve the effective UIMessageStream options by merging:
875
+ * 1. Static task-level options (from `chat.task({ uiMessageStreamOptions })`)
876
+ * 2. Per-turn overrides (from `chat.setUIMessageStreamOptions()`)
877
+ *
878
+ * Per-turn values win on conflicts. Clears the per-turn override after reading
879
+ * so it doesn't leak into subsequent turns.
880
+ * @internal
881
+ */
882
+ function resolveUIMessageStreamOptions() {
883
+ const staticOptions = locals_js_1.locals.get(chatUIStreamStaticKey) ?? {};
884
+ const perTurnOptions = locals_js_1.locals.get(chatUIStreamPerTurnKey) ?? {};
885
+ // Clear per-turn override so it doesn't leak into subsequent turns
886
+ locals_js_1.locals.set(chatUIStreamPerTurnKey, undefined);
887
+ return { ...staticOptions, ...perTurnOptions };
888
+ }
889
+ // ---------------------------------------------------------------------------
890
+ // Stop detection
891
+ // ---------------------------------------------------------------------------
892
+ /**
893
+ * Check whether the user stopped generation during the current turn.
894
+ *
895
+ * Works from **anywhere** inside a `chat.task` run — including inside
896
+ * `streamText`'s `onFinish` callback — without needing to thread the
897
+ * `stopSignal` through closures.
898
+ *
899
+ * This is especially useful when the AI SDK's `isAborted` flag is unreliable
900
+ * (e.g. when using `createUIMessageStream` + `writer.merge()`).
901
+ *
902
+ * @example
903
+ * ```ts
904
+ * onFinish: ({ isAborted }) => {
905
+ * const wasStopped = isAborted || chat.isStopped();
906
+ * if (wasStopped) {
907
+ * // handle stop
908
+ * }
909
+ * }
910
+ * ```
911
+ */
912
+ function isStopped() {
913
+ const controller = locals_js_1.locals.get(chatStopControllerKey);
914
+ return controller?.signal.aborted ?? false;
915
+ }
916
+ // ---------------------------------------------------------------------------
917
+ // Per-turn deferred work
918
+ // ---------------------------------------------------------------------------
919
+ /**
920
+ * Register a promise that runs in the background during the current turn.
921
+ *
922
+ * Use this to move non-blocking work (DB writes, analytics, etc.) out of
923
+ * the critical path. The promise runs in parallel with streaming and is
924
+ * awaited (with a 5 s timeout) before `onTurnComplete` fires.
925
+ *
926
+ * @example
927
+ * ```ts
928
+ * onTurnStart: async ({ chatId, uiMessages }) => {
929
+ * // Persist messages without blocking the LLM call
930
+ * chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
931
+ * },
932
+ * ```
933
+ */
934
+ function chatDefer(promise) {
935
+ const promises = locals_js_1.locals.get(chatDeferKey);
936
+ if (promises) {
937
+ promises.add(promise);
938
+ }
939
+ }
940
+ // ---------------------------------------------------------------------------
941
+ // Aborted message cleanup
942
+ // ---------------------------------------------------------------------------
943
+ /**
944
+ * Clean up a UIMessage that was captured during an aborted/stopped turn.
945
+ *
946
+ * When generation is stopped mid-stream, the captured message may contain:
947
+ * - Tool parts stuck in incomplete states (`partial-call`, `input-available`,
948
+ * `input-streaming`) that cause permanent UI spinners
949
+ * - Reasoning parts with `state: "streaming"` instead of `"done"`
950
+ * - Text parts with `state: "streaming"` instead of `"done"`
951
+ *
952
+ * This function returns a cleaned copy with:
953
+ * - Incomplete tool parts removed entirely
954
+ * - Reasoning and text parts marked as `"done"`
955
+ *
956
+ * `chat.task` calls this automatically when stop is detected before passing
957
+ * the response to `onTurnComplete`. Use this manually when calling `pipeChat`
958
+ * directly and capturing response messages yourself.
959
+ *
960
+ * @example
961
+ * ```ts
962
+ * onTurnComplete: async ({ responseMessage, stopped }) => {
963
+ * // Already cleaned automatically by chat.task — but if you captured
964
+ * // your own message via pipeChat, clean it manually:
965
+ * const cleaned = chat.cleanupAbortedParts(myMessage);
966
+ * await db.messages.save(cleaned);
967
+ * }
968
+ * ```
969
+ */
970
+ function cleanupAbortedParts(message) {
971
+ if (!message.parts)
972
+ return message;
973
+ const isToolPart = (part) => part.type === "tool-invocation" ||
974
+ part.type?.startsWith("tool-") ||
975
+ part.type === "dynamic-tool";
976
+ return {
977
+ ...message,
978
+ parts: message.parts
979
+ .filter((part) => {
980
+ if (!isToolPart(part))
981
+ return true;
982
+ // Remove tool parts that never completed execution.
983
+ // partial-call: input was still streaming when aborted.
984
+ // input-available: input was complete but tool never ran.
985
+ // input-streaming: input was mid-stream.
986
+ const state = part.toolInvocation?.state ?? part.state;
987
+ return state !== "partial-call" && state !== "input-available" && state !== "input-streaming";
988
+ })
989
+ .map((part) => {
990
+ // Mark streaming reasoning as done
991
+ if (part.type === "reasoning" && part.state === "streaming") {
992
+ return { ...part, state: "done" };
993
+ }
994
+ // Mark streaming text as done
995
+ if (part.type === "text" && part.state === "streaming") {
996
+ return { ...part, state: "done" };
997
+ }
998
+ return part;
999
+ }),
1000
+ };
1001
+ }
1002
+ // ---------------------------------------------------------------------------
1003
+ // Composable primitives for raw task chat
1004
+ // ---------------------------------------------------------------------------
1005
+ /**
1006
+ * Create a managed stop signal wired to the chat stop input stream.
1007
+ *
1008
+ * Call once at the start of your run. Use `signal` as the abort signal for
1009
+ * `streamText`. Call `reset()` at the start of each turn to get a fresh
1010
+ * per-turn signal. Call `cleanup()` when the run ends.
1011
+ *
1012
+ * @example
1013
+ * ```ts
1014
+ * const stop = chat.createStopSignal();
1015
+ * for (let turn = 0; turn < 100; turn++) {
1016
+ * stop.reset();
1017
+ * const result = streamText({ model, messages, abortSignal: stop.signal });
1018
+ * await chat.pipe(result);
1019
+ * // ...
1020
+ * }
1021
+ * stop.cleanup();
1022
+ * ```
1023
+ */
1024
+ function createStopSignal() {
1025
+ let controller = new AbortController();
1026
+ const sub = stopInput.on((data) => {
1027
+ controller.abort(data?.message || "stopped");
1028
+ });
1029
+ return {
1030
+ get signal() { return controller.signal; },
1031
+ reset() { controller = new AbortController(); },
1032
+ cleanup() { sub.off(); },
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Signal the frontend that the current turn is complete.
1037
+ *
1038
+ * The `TriggerChatTransport` intercepts this to close the ReadableStream
1039
+ * for the current turn. Call after piping the response stream.
1040
+ *
1041
+ * @example
1042
+ * ```ts
1043
+ * await chat.pipe(result);
1044
+ * await chat.writeTurnComplete();
1045
+ * ```
1046
+ */
1047
+ async function chatWriteTurnComplete(options) {
1048
+ await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
1049
+ }
1050
+ /**
1051
+ * Pipe a `StreamTextResult` (or similar) to the chat stream and capture
1052
+ * the assistant's response message via `onFinish`.
1053
+ *
1054
+ * Combines `toUIMessageStream()` + `onFinish` callback + `chat.pipe()`.
1055
+ * Returns the captured `UIMessage`, or `undefined` if capture failed.
1056
+ *
1057
+ * @example
1058
+ * ```ts
1059
+ * const result = streamText({ model, messages, abortSignal: signal });
1060
+ * const response = await chat.pipeAndCapture(result, { signal });
1061
+ * if (response) conversation.addResponse(response);
1062
+ * ```
1063
+ */
1064
+ async function pipeChatAndCapture(source, options) {
1065
+ let captured;
1066
+ let resolveOnFinish;
1067
+ const onFinishPromise = new Promise((r) => { resolveOnFinish = r; });
1068
+ const uiStream = source.toUIMessageStream({
1069
+ ...resolveUIMessageStreamOptions(),
1070
+ onFinish: ({ responseMessage }) => {
1071
+ captured = responseMessage;
1072
+ resolveOnFinish();
1073
+ },
1074
+ });
1075
+ await pipeChat(uiStream, { signal: options?.signal, spanName: options?.spanName ?? "stream response" });
1076
+ await onFinishPromise;
1077
+ return captured;
1078
+ }
1079
+ /**
1080
+ * Accumulates conversation messages across turns.
1081
+ *
1082
+ * Handles the transport protocol: turn 0 sends full history (replace),
1083
+ * subsequent turns send only new messages (append), regenerate sends
1084
+ * full history minus last assistant message (replace).
1085
+ *
1086
+ * @example
1087
+ * ```ts
1088
+ * const conversation = new chat.MessageAccumulator();
1089
+ * for (let turn = 0; turn < 100; turn++) {
1090
+ * const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn);
1091
+ * const result = streamText({ model, messages });
1092
+ * const response = await chat.pipeAndCapture(result);
1093
+ * if (response) await conversation.addResponse(response);
1094
+ * }
1095
+ * ```
1096
+ */
1097
+ class ChatMessageAccumulator {
1098
+ modelMessages = [];
1099
+ uiMessages = [];
1100
+ /**
1101
+ * Add incoming messages from the transport payload.
1102
+ * Returns the full accumulated model messages for `streamText`.
1103
+ */
1104
+ async addIncoming(messages, trigger, turn) {
1105
+ const cleaned = messages.map((m) => m.role === "assistant" ? cleanupAbortedParts(m) : m);
1106
+ const model = await (0, ai_1.convertToModelMessages)(cleaned);
1107
+ if (turn === 0 || trigger === "regenerate-message") {
1108
+ this.modelMessages = model;
1109
+ this.uiMessages = [...cleaned];
1110
+ }
1111
+ else {
1112
+ this.modelMessages.push(...model);
1113
+ this.uiMessages.push(...cleaned);
1114
+ }
1115
+ return this.modelMessages;
1116
+ }
1117
+ /**
1118
+ * Add the assistant's response to the accumulator.
1119
+ * Call after `pipeAndCapture` with the captured response.
1120
+ */
1121
+ async addResponse(response) {
1122
+ if (!response.id) {
1123
+ response = { ...response, id: (0, ai_1.generateId)() };
1124
+ }
1125
+ this.uiMessages.push(response);
1126
+ try {
1127
+ const msgs = await (0, ai_1.convertToModelMessages)([stripProviderMetadata(response)]);
1128
+ this.modelMessages.push(...msgs);
1129
+ }
1130
+ catch {
1131
+ // Conversion failed — skip model message accumulation for this response
1132
+ }
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Create a chat session that yields turns as an async iterator.
1137
+ *
1138
+ * Handles: preload wait, stop signals, message accumulation, turn-complete
1139
+ * signaling, and warm/suspend between turns. You control: initialization,
1140
+ * model/tool selection, persistence, and any custom per-turn logic.
1141
+ *
1142
+ * @example
1143
+ * ```ts
1144
+ * import { task } from "@trigger.dev/sdk";
1145
+ * import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
1146
+ * import { streamText } from "ai";
1147
+ * import { openai } from "@ai-sdk/openai";
1148
+ *
1149
+ * export const myChat = task({
1150
+ * id: "my-chat",
1151
+ * run: async (payload: ChatTaskWirePayload, { signal }) => {
1152
+ * const session = chat.createSession(payload, { signal });
1153
+ *
1154
+ * for await (const turn of session) {
1155
+ * const result = streamText({
1156
+ * model: openai("gpt-4o"),
1157
+ * messages: turn.messages,
1158
+ * abortSignal: turn.signal,
1159
+ * });
1160
+ * await turn.complete(result);
1161
+ * }
1162
+ * },
1163
+ * });
1164
+ * ```
1165
+ */
1166
+ function createChatSession(payload, options) {
1167
+ const { signal: runSignal, warmTimeoutInSeconds = 30, timeout = "1h", maxTurns = 100, } = options;
1168
+ return {
1169
+ [Symbol.asyncIterator]() {
1170
+ let currentPayload = payload;
1171
+ let turn = -1;
1172
+ const stop = createStopSignal();
1173
+ const accumulator = new ChatMessageAccumulator();
1174
+ return {
1175
+ async next() {
1176
+ turn++;
1177
+ // First turn: handle preload — wait for the first real message
1178
+ if (turn === 0 && currentPayload.trigger === "preload") {
1179
+ const result = await messagesInput.waitWithWarmup({
1180
+ warmTimeoutInSeconds: currentPayload.warmTimeoutInSeconds ?? warmTimeoutInSeconds,
1181
+ timeout,
1182
+ spanName: "waiting for first message",
1183
+ });
1184
+ if (!result.ok || runSignal.aborted) {
1185
+ stop.cleanup();
1186
+ return { done: true, value: undefined };
1187
+ }
1188
+ currentPayload = result.output;
1189
+ }
1190
+ // Subsequent turns: wait for the next message
1191
+ if (turn > 0) {
1192
+ const next = await messagesInput.waitWithWarmup({
1193
+ warmTimeoutInSeconds,
1194
+ timeout,
1195
+ spanName: "waiting for next message",
1196
+ });
1197
+ if (!next.ok || runSignal.aborted) {
1198
+ stop.cleanup();
1199
+ return { done: true, value: undefined };
1200
+ }
1201
+ currentPayload = next.output;
1202
+ }
1203
+ // Check limits
1204
+ if (turn >= maxTurns || runSignal.aborted) {
1205
+ stop.cleanup();
1206
+ return { done: true, value: undefined };
1207
+ }
1208
+ // Reset stop signal for this turn
1209
+ stop.reset();
1210
+ // Accumulate messages
1211
+ const messages = await accumulator.addIncoming(currentPayload.messages, currentPayload.trigger, turn);
1212
+ const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
1213
+ const turnObj = {
1214
+ number: turn,
1215
+ chatId: currentPayload.chatId,
1216
+ trigger: currentPayload.trigger,
1217
+ clientData: currentPayload.metadata,
1218
+ messages,
1219
+ uiMessages: accumulator.uiMessages,
1220
+ signal: combinedSignal,
1221
+ get stopped() { return stop.signal.aborted && !runSignal.aborted; },
1222
+ continuation: currentPayload.continuation ?? false,
1223
+ async complete(source) {
1224
+ let response;
1225
+ try {
1226
+ response = await pipeChatAndCapture(source, { signal: combinedSignal });
1227
+ }
1228
+ catch (error) {
1229
+ if (error instanceof Error && error.name === "AbortError") {
1230
+ if (runSignal.aborted) {
1231
+ // Full cancel — don't accumulate
1232
+ await chatWriteTurnComplete();
1233
+ return undefined;
1234
+ }
1235
+ // Stop — fall through to accumulate partial response
1236
+ }
1237
+ else {
1238
+ throw error;
1239
+ }
1240
+ }
1241
+ if (response) {
1242
+ const cleaned = (stop.signal.aborted && !runSignal.aborted)
1243
+ ? cleanupAbortedParts(response)
1244
+ : response;
1245
+ await accumulator.addResponse(cleaned);
1246
+ }
1247
+ await chatWriteTurnComplete();
1248
+ return response;
1249
+ },
1250
+ async addResponse(response) {
1251
+ await accumulator.addResponse(response);
1252
+ },
1253
+ async done() {
1254
+ await chatWriteTurnComplete();
1255
+ },
1256
+ };
1257
+ return { done: false, value: turnObj };
1258
+ },
1259
+ async return() {
1260
+ stop.cleanup();
1261
+ return { done: true, value: undefined };
1262
+ },
1263
+ };
1264
+ },
1265
+ };
1266
+ }
592
1267
  // ---------------------------------------------------------------------------
593
1268
  // chat.local — per-run typed data with Proxy access
594
1269
  // ---------------------------------------------------------------------------
@@ -596,8 +1271,25 @@ function setWarmTimeoutInSeconds(seconds) {
596
1271
  const CHAT_LOCAL_KEY = Symbol("chatLocalKey");
597
1272
  /** @internal Symbol for storing the dirty-tracking locals key. */
598
1273
  const CHAT_LOCAL_DIRTY_KEY = Symbol("chatLocalDirtyKey");
599
- /** @internal Counter for generating unique locals IDs. */
600
- let chatLocalCounter = 0;
1274
+ const chatLocalRegistry = new Set();
1275
+ /** @internal Run-scoped flag to ensure hydration happens at most once per run. */
1276
+ const chatLocalsHydratedKey = locals_js_1.locals.create("chat.locals.hydrated");
1277
+ /**
1278
+ * Hydrate chat.local values from subtask metadata (set by toolFromTask).
1279
+ * Runs once per run — subsequent calls are no-ops.
1280
+ * @internal
1281
+ */
1282
+ function hydrateLocalsFromMetadata() {
1283
+ if (locals_js_1.locals.get(chatLocalsHydratedKey))
1284
+ return;
1285
+ locals_js_1.locals.set(chatLocalsHydratedKey, true);
1286
+ const opts = metadata_js_1.metadata.get(METADATA_KEY);
1287
+ if (!opts?.chatLocals)
1288
+ return;
1289
+ for (const [id, value] of Object.entries(opts.chatLocals)) {
1290
+ locals_js_1.locals.set(locals_js_1.locals.create(id), value);
1291
+ }
1292
+ }
601
1293
  /**
602
1294
  * Creates a per-run typed data object accessible from anywhere during task execution.
603
1295
  *
@@ -606,12 +1298,16 @@ let chatLocalCounter = 0;
606
1298
  *
607
1299
  * Multiple locals can coexist — each gets its own isolated run-scoped storage.
608
1300
  *
1301
+ * The `id` is required and must be unique across all `chat.local()` calls in
1302
+ * your project. It's used to serialize values into subtask metadata so that
1303
+ * `ai.tool()` subtasks can auto-hydrate parent locals (read-only).
1304
+ *
609
1305
  * @example
610
1306
  * ```ts
611
1307
  * import { chat } from "@trigger.dev/sdk/ai";
612
1308
  *
613
- * const userPrefs = chat.local<{ theme: string; language: string }>();
614
- * const gameState = chat.local<{ score: number; streak: number }>();
1309
+ * const userPrefs = chat.local<{ theme: string; language: string }>({ id: "userPrefs" });
1310
+ * const gameState = chat.local<{ score: number; streak: number }>({ id: "gameState" });
615
1311
  *
616
1312
  * export const myChat = chat.task({
617
1313
  * id: "my-chat",
@@ -635,9 +1331,11 @@ let chatLocalCounter = 0;
635
1331
  * });
636
1332
  * ```
637
1333
  */
638
- function chatLocal() {
639
- const localKey = locals_js_1.locals.create(`chat.local.${chatLocalCounter++}`);
640
- const dirtyKey = locals_js_1.locals.create(`chat.local.${chatLocalCounter++}.dirty`);
1334
+ function chatLocal(options) {
1335
+ const id = `chat.local.${options.id}`;
1336
+ const localKey = locals_js_1.locals.create(id);
1337
+ const dirtyKey = locals_js_1.locals.create(`${id}.dirty`);
1338
+ chatLocalRegistry.add({ key: localKey, id });
641
1339
  const target = {};
642
1340
  target[CHAT_LOCAL_KEY] = localKey;
643
1341
  target[CHAT_LOCAL_DIRTY_KEY] = dirtyKey;
@@ -664,7 +1362,11 @@ function chatLocal() {
664
1362
  }
665
1363
  if (prop === "get") {
666
1364
  return () => {
667
- const current = locals_js_1.locals.get(localKey);
1365
+ let current = locals_js_1.locals.get(localKey);
1366
+ if (current === undefined) {
1367
+ hydrateLocalsFromMetadata();
1368
+ current = locals_js_1.locals.get(localKey);
1369
+ }
668
1370
  if (current === undefined) {
669
1371
  throw new Error("local.get() called before initialization. Call local.init() first.");
670
1372
  }
@@ -674,11 +1376,20 @@ function chatLocal() {
674
1376
  // toJSON for serialization (JSON.stringify(local))
675
1377
  if (prop === "toJSON") {
676
1378
  return () => {
677
- const current = locals_js_1.locals.get(localKey);
1379
+ let current = locals_js_1.locals.get(localKey);
1380
+ if (current === undefined) {
1381
+ hydrateLocalsFromMetadata();
1382
+ current = locals_js_1.locals.get(localKey);
1383
+ }
678
1384
  return current ? { ...current } : undefined;
679
1385
  };
680
1386
  }
681
- const current = locals_js_1.locals.get(localKey);
1387
+ let current = locals_js_1.locals.get(localKey);
1388
+ if (current === undefined) {
1389
+ // Auto-hydrate from parent metadata in subtask context
1390
+ hydrateLocalsFromMetadata();
1391
+ current = locals_js_1.locals.get(localKey);
1392
+ }
682
1393
  if (current === undefined)
683
1394
  return undefined;
684
1395
  return current[prop];
@@ -699,17 +1410,29 @@ function chatLocal() {
699
1410
  has(_target, prop) {
700
1411
  if (typeof prop === "symbol")
701
1412
  return prop in _target;
702
- const current = locals_js_1.locals.get(localKey);
1413
+ let current = locals_js_1.locals.get(localKey);
1414
+ if (current === undefined) {
1415
+ hydrateLocalsFromMetadata();
1416
+ current = locals_js_1.locals.get(localKey);
1417
+ }
703
1418
  return current !== undefined && prop in current;
704
1419
  },
705
1420
  ownKeys() {
706
- const current = locals_js_1.locals.get(localKey);
1421
+ let current = locals_js_1.locals.get(localKey);
1422
+ if (current === undefined) {
1423
+ hydrateLocalsFromMetadata();
1424
+ current = locals_js_1.locals.get(localKey);
1425
+ }
707
1426
  return current ? Reflect.ownKeys(current) : [];
708
1427
  },
709
1428
  getOwnPropertyDescriptor(_target, prop) {
710
1429
  if (typeof prop === "symbol")
711
1430
  return undefined;
712
- const current = locals_js_1.locals.get(localKey);
1431
+ let current = locals_js_1.locals.get(localKey);
1432
+ if (current === undefined) {
1433
+ hydrateLocalsFromMetadata();
1434
+ current = locals_js_1.locals.get(localKey);
1435
+ }
713
1436
  if (current === undefined || !(prop in current))
714
1437
  return undefined;
715
1438
  return {
@@ -736,6 +1459,28 @@ exports.chat = {
736
1459
  setTurnTimeoutInSeconds,
737
1460
  /** Override the warm timeout at runtime. See {@link setWarmTimeoutInSeconds}. */
738
1461
  setWarmTimeoutInSeconds,
1462
+ /** Override toUIMessageStream() options for the current turn. See {@link setUIMessageStreamOptions}. */
1463
+ setUIMessageStreamOptions,
1464
+ /** Check if the current turn was stopped by the user. See {@link isStopped}. */
1465
+ isStopped,
1466
+ /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
1467
+ cleanupAbortedParts,
1468
+ /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
1469
+ defer: chatDefer,
1470
+ /** Typed chat output stream for writing custom chunks or piping from subtasks. */
1471
+ stream: chatStream,
1472
+ /** Pre-built input stream for receiving messages from the transport. */
1473
+ messages: messagesInput,
1474
+ /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */
1475
+ createStopSignal,
1476
+ /** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */
1477
+ writeTurnComplete: chatWriteTurnComplete,
1478
+ /** Pipe a stream and capture the response message. See {@link pipeChatAndCapture}. */
1479
+ pipeAndCapture: pipeChatAndCapture,
1480
+ /** Message accumulator class for raw task chat. See {@link ChatMessageAccumulator}. */
1481
+ MessageAccumulator: ChatMessageAccumulator,
1482
+ /** Create a chat session (async iterator). See {@link createChatSession}. */
1483
+ createSession: createChatSession,
739
1484
  };
740
1485
  /**
741
1486
  * Writes a turn-complete control chunk to the chat output stream.