@trigger.dev/sdk 0.0.0-chat-prerelease-20260413144407 → 0.0.0-chat-prerelease-20260414181032

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.
@@ -724,12 +724,18 @@ export type PipeChatOptions = {
724
724
  * Set static defaults via `uiMessageStreamOptions` on `chat.agent()`, or
725
725
  * override per-turn via `chat.setUIMessageStreamOptions()`.
726
726
  *
727
- * `onFinish`, `originalMessages`, and `generateMessageId` are omitted because
728
- * they are managed internally for response capture and message accumulation.
727
+ * `onFinish` is omitted because it is managed internally for response capture.
729
728
  * Use `streamText`'s `onFinish` for custom finish handling, or drop down to
730
729
  * raw task mode with `chat.pipe()` for full control.
730
+ *
731
+ * `originalMessages` is omitted because it is automatically set from the
732
+ * accumulated conversation history, ensuring message IDs are reused across
733
+ * turns (e.g. for tool approval continuations).
734
+ *
735
+ * `generateMessageId` can be set to control ID generation for response
736
+ * messages (e.g. UUID-v7). If not set, the AI SDK's default `generateId` is used.
731
737
  */
732
- export type ChatUIMessageStreamOptions<TUIM extends UIMessage = UIMessage> = Omit<UIMessageStreamOptions<TUIM>, "onFinish" | "originalMessages" | "generateMessageId">;
738
+ export type ChatUIMessageStreamOptions<TUIM extends UIMessage = UIMessage> = Omit<UIMessageStreamOptions<TUIM>, "onFinish" | "originalMessages">;
733
739
  /**
734
740
  * An object with a `toUIMessageStream()` method (e.g. `StreamTextResult` from `streamText()`).
735
741
  */
@@ -1285,9 +1291,10 @@ export type ChatAgentOptions<TIdentifier extends string, TClientDataSchema exten
1285
1291
  * inside `run()` or lifecycle hooks. Per-turn values are merged on top
1286
1292
  * of these defaults (per-turn wins on conflicts).
1287
1293
  *
1288
- * `onFinish`, `originalMessages`, and `generateMessageId` are managed
1289
- * internally and cannot be overridden here. Use `streamText`'s `onFinish`
1290
- * for custom finish handling, or drop to raw task mode for full control.
1294
+ * `onFinish` and `originalMessages` are managed internally and cannot be
1295
+ * overridden here. Use `streamText`'s `onFinish` for custom finish
1296
+ * handling. `generateMessageId` can be set to control response message
1297
+ * ID generation (e.g. UUID-v7).
1291
1298
  *
1292
1299
  * @example
1293
1300
  * ```ts
@@ -2097,6 +2104,14 @@ export declare const chat: {
2097
2104
  inject: typeof injectBackgroundContext;
2098
2105
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */
2099
2106
  stream: import("@trigger.dev/core/v3").RealtimeDefinedStream<UIMessageChunk>;
2107
+ /** Write data parts that persist to the response message. See {@link chatResponse}. */
2108
+ response: {
2109
+ /**
2110
+ * Write a single chunk. Non-transient data parts are accumulated into the
2111
+ * response message; everything else is stream-only.
2112
+ */
2113
+ write(part: UIMessageChunk): void;
2114
+ };
2100
2115
  /** Pre-built input stream for receiving messages from the transport. */
2101
2116
  messages: import("@trigger.dev/core/v3").RealtimeDefinedInputStream<ChatTaskWirePayload<UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>, unknown>>;
2102
2117
  /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */
@@ -243,6 +243,43 @@ exports.CHAT_STREAM_KEY = chat_constants_js_1.CHAT_STREAM_KEY;
243
243
  * ```
244
244
  */
245
245
  const chatStream = streams_js_1.streams.define({ id: chat_constants_js_1.CHAT_STREAM_KEY });
246
+ // ---------------------------------------------------------------------------
247
+ // chat.response — write data parts that persist to the response message
248
+ // ---------------------------------------------------------------------------
249
+ /**
250
+ * Write data parts that both stream to the frontend AND persist in
251
+ * `onTurnComplete`'s `responseMessage` and `uiMessages`.
252
+ *
253
+ * Non-transient data chunks (`type` starts with `data-`, no `transient: true`)
254
+ * are queued for accumulation into the assistant response message.
255
+ * Transient or non-data chunks are streamed only (same as `chat.stream`).
256
+ *
257
+ * @example
258
+ * ```ts
259
+ * // Persists to responseMessage.parts
260
+ * chat.response.write({ type: "data-handover", data: { context: summary } });
261
+ *
262
+ * // Transient — streams only, not in responseMessage
263
+ * chat.response.write({ type: "data-progress", data: { percent: 50 }, transient: true });
264
+ * ```
265
+ */
266
+ const chatResponse = {
267
+ /**
268
+ * Write a single chunk. Non-transient data parts are accumulated into the
269
+ * response message; everything else is stream-only.
270
+ */
271
+ write(part) {
272
+ queueResponsePart(part);
273
+ const { waitUntilComplete } = streams_js_1.streams.writer(exports.CHAT_STREAM_KEY, {
274
+ spanName: "chat.response.write",
275
+ collapsed: true,
276
+ execute: ({ write }) => {
277
+ write(part);
278
+ },
279
+ });
280
+ waitUntilComplete().catch(() => { });
281
+ },
282
+ };
246
283
  /**
247
284
  * Creates a lazy ChatWriter that only opens a realtime stream on first use.
248
285
  * Call `flush()` after the callback returns to await stream completion.
@@ -274,6 +311,7 @@ function createLazyChatWriter() {
274
311
  writer: {
275
312
  write(part) {
276
313
  ensureInitialized();
314
+ queueResponsePart(part);
277
315
  writeImpl(part);
278
316
  },
279
317
  merge(stream) {
@@ -400,6 +438,30 @@ const chatPendingMessagesKey = locals_js_1.locals.create("chat.pendingMessages")
400
438
  const chatSteeringQueueKey = locals_js_1.locals.create("chat.steeringQueue");
401
439
  /** @internal — IDs of messages that were successfully injected via prepareStep */
402
440
  const chatInjectedMessageIdsKey = locals_js_1.locals.create("chat.injectedMessageIds");
441
+ /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
442
+ const chatResponsePartsKey = locals_js_1.locals.create("chat.responseParts");
443
+ /**
444
+ * Check if a chunk is a non-transient data part that should persist to the response message.
445
+ * @internal
446
+ */
447
+ function isNonTransientDataPart(part) {
448
+ if (typeof part !== "object" || part === null)
449
+ return false;
450
+ const p = part;
451
+ return typeof p.type === "string" && p.type.startsWith("data-") && p.transient !== true;
452
+ }
453
+ /**
454
+ * Queue a chunk for accumulation into the response message (if it's a non-transient data part).
455
+ * Called by `chat.response.write()` and `ChatWriter.write()`.
456
+ * @internal
457
+ */
458
+ function queueResponsePart(part) {
459
+ if (!isNonTransientDataPart(part))
460
+ return;
461
+ const parts = locals_js_1.locals.get(chatResponsePartsKey) ?? [];
462
+ parts.push(part);
463
+ locals_js_1.locals.set(chatResponsePartsKey, parts);
464
+ }
403
465
  /**
404
466
  * Check that no tool calls are in-flight in a step's content.
405
467
  * Used before compaction to avoid losing tool state mid-execution.
@@ -536,6 +598,7 @@ async function chatCompact(messages, steps, options) {
536
598
  type: "data-compaction",
537
599
  id: compactionId,
538
600
  data: { status: "compacting", totalTokens },
601
+ transient: true,
539
602
  });
540
603
  // Generate summary
541
604
  summary = await options.summarize(messages);
@@ -576,6 +639,7 @@ async function chatCompact(messages, steps, options) {
576
639
  type: "data-compaction",
577
640
  id: compactionId,
578
641
  data: { status: "complete", totalTokens },
642
+ transient: true,
579
643
  });
580
644
  write({ type: "finish-step" });
581
645
  },
@@ -1174,6 +1238,7 @@ function chatAgent(options) {
1174
1238
  locals_js_1.locals.set(chatDeferKey, new Set());
1175
1239
  locals_js_1.locals.set(chatCompactionStateKey, undefined);
1176
1240
  locals_js_1.locals.set(chatSteeringQueueKey, []);
1241
+ locals_js_1.locals.set(chatResponsePartsKey, []);
1177
1242
  // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
1178
1243
  // by deferred work from the previous turn's onTurnComplete need to
1179
1244
  // survive into the next turn. The queue is drained before run().
@@ -1291,11 +1356,34 @@ function chatAgent(options) {
1291
1356
  // No new user messages for regenerate — just the response (added below)
1292
1357
  }
1293
1358
  else {
1294
- // Submit: frontend sent only the new user message(s). Append to accumulator.
1295
- accumulatedMessages.push(...incomingModelMessages);
1296
- accumulatedUIMessages.push(...cleanedUIMessages);
1297
- turnNewModelMessages.push(...incomingModelMessages);
1298
- turnNewUIMessages.push(...cleanedUIMessages);
1359
+ // Submit: check if any incoming message updates an existing one (by ID).
1360
+ // This handles tool approval responses, where the frontend resends the
1361
+ // assistant message with updated tool parts (approval-responded).
1362
+ // IDs match because we always pass generateMessageId + originalMessages
1363
+ // to toUIMessageStream, so the backend's start chunk carries the same
1364
+ // messageId that the frontend uses.
1365
+ let replaced = false;
1366
+ for (const incoming of cleanedUIMessages) {
1367
+ const idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
1368
+ if (idx !== -1) {
1369
+ accumulatedUIMessages[idx] = incoming;
1370
+ replaced = true;
1371
+ }
1372
+ else {
1373
+ accumulatedUIMessages.push(incoming);
1374
+ turnNewUIMessages.push(incoming);
1375
+ }
1376
+ }
1377
+ if (replaced) {
1378
+ // Reconvert all model messages since a replacement changes the structure
1379
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages);
1380
+ }
1381
+ else {
1382
+ accumulatedMessages.push(...incomingModelMessages);
1383
+ }
1384
+ if (turnNewUIMessages.length > 0) {
1385
+ turnNewModelMessages.push(...(await toModelMessages(turnNewUIMessages)));
1386
+ }
1299
1387
  }
1300
1388
  // Mint a scoped public access token once per turn, reused for
1301
1389
  // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
@@ -1408,9 +1496,14 @@ function chatAgent(options) {
1408
1496
  let onFinishAttached = false;
1409
1497
  let runResult;
1410
1498
  try {
1411
- // Drain any messages injected by background work (e.g. self-review from previous turn)
1499
+ // Drain any messages injected by background work (e.g. self-review from previous turn).
1500
+ // Skip if the last message is a tool message — appending after it would
1501
+ // prevent streamText from finding pending tool approvals (it checks
1502
+ // the last message). The queued messages will be picked up by prepareStep
1503
+ // at the next step boundary instead.
1504
+ const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
1412
1505
  const bgQueue = locals_js_1.locals.get(chatBackgroundQueueKey);
1413
- if (bgQueue && bgQueue.length > 0) {
1506
+ if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
1414
1507
  accumulatedMessages.push(...bgQueue.splice(0));
1415
1508
  }
1416
1509
  runResult = await userRun({
@@ -1430,10 +1523,20 @@ function chatAgent(options) {
1430
1523
  // Auto-pipe if the run function returned a StreamTextResult or similar,
1431
1524
  // but only if pipeChat() wasn't already called manually during this turn.
1432
1525
  // We call toUIMessageStream ourselves to attach onFinish for response capture.
1526
+ // Pass originalMessages so the AI SDK reuses message IDs across turns
1527
+ // (e.g. for tool approval continuations / HITL flows).
1433
1528
  if ((locals_js_1.locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(runResult)) {
1434
1529
  onFinishAttached = true;
1530
+ const resolvedOptions = resolveUIMessageStreamOptions();
1435
1531
  const uiStream = runResult.toUIMessageStream({
1436
- ...resolveUIMessageStreamOptions(),
1532
+ ...resolvedOptions,
1533
+ // Pass originalMessages so the AI SDK reuses message IDs across
1534
+ // turns (e.g. for tool approval continuations / HITL flows).
1535
+ originalMessages: accumulatedUIMessages,
1536
+ // Always provide generateMessageId so the start chunk carries a
1537
+ // messageId. Without this, the frontend and backend generate IDs
1538
+ // independently and they won't match for ID-based dedup.
1539
+ generateMessageId: resolvedOptions.generateMessageId ?? ai_1.generateId,
1437
1540
  onFinish: ({ responseMessage }) => {
1438
1541
  capturedResponseMessage = responseMessage;
1439
1542
  resolveOnFinish();
@@ -1554,6 +1657,15 @@ function chatAgent(options) {
1554
1657
  if (!capturedResponseMessage.id) {
1555
1658
  capturedResponseMessage = { ...capturedResponseMessage, id: (0, ai_1.generateId)() };
1556
1659
  }
1660
+ // Append any non-transient data parts queued via chat.response or writer.write()
1661
+ const queuedParts = locals_js_1.locals.get(chatResponsePartsKey);
1662
+ if (queuedParts && queuedParts.length > 0) {
1663
+ capturedResponseMessage = {
1664
+ ...capturedResponseMessage,
1665
+ parts: [...capturedResponseMessage.parts, ...queuedParts],
1666
+ };
1667
+ locals_js_1.locals.set(chatResponsePartsKey, []);
1668
+ }
1557
1669
  accumulatedUIMessages.push(capturedResponseMessage);
1558
1670
  turnNewUIMessages.push(capturedResponseMessage);
1559
1671
  try {
@@ -1567,10 +1679,21 @@ function chatAgent(options) {
1567
1679
  // Conversion failed — skip accumulation for this turn
1568
1680
  }
1569
1681
  }
1570
- // TODO: When the user calls `pipeChat` manually instead of returning a
1571
- // StreamTextResult, we don't have access to onFinish. A future iteration
1572
- // should let manual-mode users report back response messages for
1573
- // accumulation (e.g. via a `chat.addMessages()` helper).
1682
+ // If there's no captured response (manual pipe mode) but there are
1683
+ // queued data parts, create a minimal response message to hold them.
1684
+ if (!capturedResponseMessage) {
1685
+ const remainingParts = locals_js_1.locals.get(chatResponsePartsKey);
1686
+ if (remainingParts && remainingParts.length > 0) {
1687
+ capturedResponseMessage = {
1688
+ id: (0, ai_1.generateId)(),
1689
+ role: "assistant",
1690
+ parts: [...remainingParts],
1691
+ };
1692
+ locals_js_1.locals.set(chatResponsePartsKey, []);
1693
+ accumulatedUIMessages.push(capturedResponseMessage);
1694
+ turnNewUIMessages.push(capturedResponseMessage);
1695
+ }
1696
+ }
1574
1697
  if (runSignal.aborted)
1575
1698
  return "exit";
1576
1699
  // Await deferred background work (e.g. DB writes from onTurnStart)
@@ -1612,6 +1735,7 @@ function chatAgent(options) {
1612
1735
  type: "data-compaction",
1613
1736
  id: compactionId,
1614
1737
  data: { status: "compacting", totalTokens: turnUsage.totalTokens },
1738
+ transient: true,
1615
1739
  });
1616
1740
  const summary = await outerCompaction.summarize({
1617
1741
  messages: accumulatedMessages,
@@ -1673,6 +1797,7 @@ function chatAgent(options) {
1673
1797
  type: "data-compaction",
1674
1798
  id: compactionId,
1675
1799
  data: { status: "complete", totalTokens: turnUsage.totalTokens },
1800
+ transient: true,
1676
1801
  });
1677
1802
  },
1678
1803
  });
@@ -1746,6 +1871,22 @@ function chatAgent(options) {
1746
1871
  },
1747
1872
  });
1748
1873
  }
1874
+ // Drain any late response parts added during onBeforeTurnComplete
1875
+ const lateParts = locals_js_1.locals.get(chatResponsePartsKey);
1876
+ if (lateParts && lateParts.length > 0 && capturedResponseMessage) {
1877
+ const idx = accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id);
1878
+ if (idx !== -1) {
1879
+ const msg = accumulatedUIMessages[idx];
1880
+ accumulatedUIMessages[idx] = {
1881
+ ...msg,
1882
+ parts: [...(msg.parts ?? []), ...lateParts],
1883
+ };
1884
+ capturedResponseMessage = accumulatedUIMessages[idx];
1885
+ turnCompleteEvent.responseMessage = capturedResponseMessage;
1886
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
1887
+ }
1888
+ locals_js_1.locals.set(chatResponsePartsKey, []);
1889
+ }
1749
1890
  // Write turn-complete control chunk — closes the frontend stream.
1750
1891
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
1751
1892
  // Fire onTurnComplete — stream is closed, use for persistence.
@@ -2753,6 +2894,8 @@ function createChatSession(payload, options) {
2753
2894
  }
2754
2895
  // Reset stop signal for this turn
2755
2896
  stop.reset();
2897
+ // Reset per-turn state
2898
+ locals_js_1.locals.set(chatResponsePartsKey, []);
2756
2899
  // Set up steering queue and pending messages config in locals
2757
2900
  // so toStreamTextOptions() auto-injects prepareStep for steering
2758
2901
  const turnSteeringQueue = [];
@@ -2849,8 +2992,26 @@ function createChatSession(payload, options) {
2849
2992
  const cleaned = stop.signal.aborted && !runSignal.aborted
2850
2993
  ? cleanupAbortedParts(response)
2851
2994
  : response;
2995
+ // Append any non-transient data parts queued via chat.response or writer.write()
2996
+ const queuedParts = locals_js_1.locals.get(chatResponsePartsKey);
2997
+ if (queuedParts && queuedParts.length > 0) {
2998
+ cleaned.parts = [...(cleaned.parts ?? []), ...queuedParts];
2999
+ locals_js_1.locals.set(chatResponsePartsKey, []);
3000
+ }
2852
3001
  await accumulator.addResponse(cleaned);
2853
3002
  }
3003
+ else {
3004
+ // No response (manual pipe mode) but there are queued data parts
3005
+ const queuedParts = locals_js_1.locals.get(chatResponsePartsKey);
3006
+ if (queuedParts && queuedParts.length > 0) {
3007
+ await accumulator.addResponse({
3008
+ id: (0, ai_1.generateId)(),
3009
+ role: "assistant",
3010
+ parts: queuedParts,
3011
+ });
3012
+ locals_js_1.locals.set(chatResponsePartsKey, []);
3013
+ }
3014
+ }
2854
3015
  // Capture token usage from the streamText result
2855
3016
  let turnUsage;
2856
3017
  if (typeof source.totalUsage?.then === "function") {
@@ -2917,6 +3078,12 @@ function createChatSession(payload, options) {
2917
3078
  return response;
2918
3079
  },
2919
3080
  async addResponse(response) {
3081
+ // Append any non-transient data parts queued via chat.response or writer.write()
3082
+ const queuedParts = locals_js_1.locals.get(chatResponsePartsKey);
3083
+ if (queuedParts && queuedParts.length > 0) {
3084
+ response = { ...response, parts: [...(response.parts ?? []), ...queuedParts] };
3085
+ locals_js_1.locals.set(chatResponsePartsKey, []);
3086
+ }
2920
3087
  await accumulator.addResponse(response);
2921
3088
  },
2922
3089
  async done() {
@@ -3219,6 +3386,8 @@ exports.chat = {
3219
3386
  inject: injectBackgroundContext,
3220
3387
  /** Typed chat output stream for writing custom chunks or piping from subtasks. */
3221
3388
  stream: chatStream,
3389
+ /** Write data parts that persist to the response message. See {@link chatResponse}. */
3390
+ response: chatResponse,
3222
3391
  /** Pre-built input stream for receiving messages from the transport. */
3223
3392
  messages: messagesInput,
3224
3393
  /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */