@trigger.dev/sdk 4.7.4 → 4.8.0-webhooks.20261020092920

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 (100) hide show
  1. package/dist/commonjs/v3/ai.d.ts +626 -8
  2. package/dist/commonjs/v3/ai.js +2343 -185
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat.d.ts +23 -0
  5. package/dist/commonjs/v3/chat.js.map +1 -1
  6. package/dist/commonjs/v3/index.d.ts +2 -0
  7. package/dist/commonjs/v3/index.js +23 -17
  8. package/dist/commonjs/v3/index.js.map +1 -1
  9. package/dist/commonjs/v3/modelFileDownload.d.ts +10 -0
  10. package/dist/commonjs/v3/modelFileDownload.js +164 -0
  11. package/dist/commonjs/v3/modelFileDownload.js.map +1 -0
  12. package/dist/commonjs/v3/query.d.ts +434 -1
  13. package/dist/commonjs/v3/query.js +115 -0
  14. package/dist/commonjs/v3/query.js.map +1 -1
  15. package/dist/commonjs/v3/test/index.d.ts +2 -1
  16. package/dist/commonjs/v3/test/index.js +4 -1
  17. package/dist/commonjs/v3/test/index.js.map +1 -1
  18. package/dist/commonjs/v3/test/mock-channel-connector.d.ts +172 -0
  19. package/dist/commonjs/v3/test/mock-channel-connector.js +173 -0
  20. package/dist/commonjs/v3/test/mock-channel-connector.js.map +1 -0
  21. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +81 -1
  22. package/dist/commonjs/v3/test/mock-chat-agent.js +137 -10
  23. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  24. package/dist/commonjs/v3/transcriptStorage.d.ts +44 -1
  25. package/dist/commonjs/v3/transcriptStorage.js +24 -0
  26. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  27. package/dist/commonjs/v3/webhookWaiters.d.ts +80 -0
  28. package/dist/commonjs/v3/webhookWaiters.js +156 -0
  29. package/dist/commonjs/v3/webhookWaiters.js.map +1 -0
  30. package/dist/commonjs/v3/webhooks.d.ts +184 -2
  31. package/dist/commonjs/v3/webhooks.js +323 -1
  32. package/dist/commonjs/v3/webhooks.js.map +1 -1
  33. package/dist/commonjs/version.js +1 -1
  34. package/dist/esm/v3/ai.d.ts +626 -8
  35. package/dist/esm/v3/ai.js +2336 -184
  36. package/dist/esm/v3/ai.js.map +1 -1
  37. package/dist/esm/v3/chat.d.ts +23 -0
  38. package/dist/esm/v3/chat.js.map +1 -1
  39. package/dist/esm/v3/index.d.ts +2 -0
  40. package/dist/esm/v3/index.js +1 -0
  41. package/dist/esm/v3/index.js.map +1 -1
  42. package/dist/esm/v3/modelFileDownload.d.ts +10 -0
  43. package/dist/esm/v3/modelFileDownload.js +161 -0
  44. package/dist/esm/v3/modelFileDownload.js.map +1 -0
  45. package/dist/esm/v3/query.d.ts +434 -1
  46. package/dist/esm/v3/query.js +115 -0
  47. package/dist/esm/v3/query.js.map +1 -1
  48. package/dist/esm/v3/test/index.d.ts +2 -1
  49. package/dist/esm/v3/test/index.js +2 -1
  50. package/dist/esm/v3/test/index.js.map +1 -1
  51. package/dist/esm/v3/test/mock-channel-connector.d.ts +172 -0
  52. package/dist/esm/v3/test/mock-channel-connector.js +170 -0
  53. package/dist/esm/v3/test/mock-channel-connector.js.map +1 -0
  54. package/dist/esm/v3/test/mock-chat-agent.d.ts +81 -1
  55. package/dist/esm/v3/test/mock-chat-agent.js +137 -11
  56. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  57. package/dist/esm/v3/transcriptStorage.d.ts +44 -1
  58. package/dist/esm/v3/transcriptStorage.js +24 -0
  59. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  60. package/dist/esm/v3/webhookWaiters.d.ts +80 -0
  61. package/dist/esm/v3/webhookWaiters.js +149 -0
  62. package/dist/esm/v3/webhookWaiters.js.map +1 -0
  63. package/dist/esm/v3/webhooks.d.ts +184 -2
  64. package/dist/esm/v3/webhooks.js +319 -1
  65. package/dist/esm/v3/webhooks.js.map +1 -1
  66. package/dist/esm/version.js +1 -1
  67. package/docs/ai-chat/actions.mdx +6 -1
  68. package/docs/ai-chat/backend.mdx +40 -0
  69. package/docs/ai-chat/patterns/redacting-telemetry.mdx +117 -0
  70. package/docs/ai-chat/pending-messages.mdx +2 -0
  71. package/docs/ai-chat/reference.mdx +53 -2
  72. package/docs/ai-chat/transcript-storage.mdx +1 -1
  73. package/docs/apikeys.mdx +6 -4
  74. package/docs/cli-runs-commands.mdx +2 -2
  75. package/docs/cli-tasks-commands.mdx +95 -0
  76. package/docs/config/config-file.mdx +58 -0
  77. package/docs/config/extensions/prismaExtension.mdx +4 -4
  78. package/docs/deploy-environment-variables.mdx +4 -0
  79. package/docs/deployment/atomic-deployment.mdx +3 -3
  80. package/docs/deployment/overview.mdx +1 -1
  81. package/docs/deployment/preview-branches.mdx +1 -1
  82. package/docs/github-actions.mdx +3 -3
  83. package/docs/limits.mdx +6 -0
  84. package/docs/manual-setup.mdx +1 -1
  85. package/docs/mcp-tools.mdx +99 -3
  86. package/docs/observability/dashboards.mdx +62 -0
  87. package/docs/runs/max-duration.mdx +4 -0
  88. package/docs/self-hosting/env/supervisor.mdx +1 -0
  89. package/docs/webhooks/channels.mdx +132 -0
  90. package/docs/webhooks/connect.mdx +84 -0
  91. package/docs/webhooks/deliveries.mdx +96 -0
  92. package/docs/webhooks/filters.mdx +142 -0
  93. package/docs/webhooks/human-in-the-loop.mdx +155 -0
  94. package/docs/webhooks/linear.mdx +189 -0
  95. package/docs/webhooks/overview.mdx +103 -0
  96. package/docs/webhooks/session-routing.mdx +138 -0
  97. package/docs/webhooks/slack.mdx +368 -0
  98. package/docs/webhooks/sources.mdx +221 -0
  99. package/docs/webhooks/waiters.mdx +194 -0
  100. package/package.json +4 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.chat = exports.__buildManagedStreamTextOptionsForTests = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = exports.snapshotTranscriptStorage = exports.reduceTranscriptChanges = exports.memoryTranscriptStorage = exports.defaultStorage = exports.__writeChatSnapshotProductionPathForTests = exports.__setWriteChatSnapshotImplForTests = exports.__setReadChatSnapshotImplForTests = exports.__readChatSnapshotProductionPathForTests = void 0;
3
+ exports.chat = exports.CHANNEL_TURN_ERROR_MESSAGE = exports.__buildManagedStreamTextOptionsForTests = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = exports.snapshotTranscriptStorage = exports.reduceTranscriptChanges = exports.memoryTranscriptStorage = exports.defaultStorage = exports.__writeChatSnapshotProductionPathForTests = exports.__setWriteChatSnapshotImplForTests = exports.__setReadChatSnapshotImplForTests = exports.__readChatSnapshotProductionPathForTests = void 0;
4
4
  exports.__setTranscriptStorageForTests = __setTranscriptStorageForTests;
5
5
  exports.__mergeByIdReplaceWinsForTests = __mergeByIdReplaceWinsForTests;
6
6
  exports.__setReplaySessionOutTailImplForTests = __setReplaySessionOutTailImplForTests;
@@ -11,7 +11,16 @@ exports.__resetChatInputRouterForTests = __resetChatInputRouterForTests;
11
11
  exports.__chatInputCheckpointForTests = __chatInputCheckpointForTests;
12
12
  exports.__findLatestSessionInCheckpointForTests = __findLatestSessionInCheckpointForTests;
13
13
  exports.buildSkillTools = buildSkillTools;
14
+ exports.chatChannelCustom = chatChannelCustom;
15
+ exports.resolveReactionChoice = resolveReactionChoice;
16
+ exports.__makeChannelStreamEditorForTests = makeChannelStreamEditor;
17
+ exports.__makeChannelStreamTapForTests = makeChannelStreamTap;
18
+ exports.__makeChannelProgressObserverForTests = makeChannelProgressObserver;
19
+ exports.__makeChannelLifecycleReporterForTests = makeChannelLifecycleReporter;
20
+ exports.__waitForChannelEgressForTests = __waitForChannelEgressForTests;
14
21
  const v3_1 = require("@trigger.dev/core/v3");
22
+ const webhooks_js_1 = require("./webhooks.js");
23
+ const modelFileDownload_js_1 = require("./modelFileDownload.js");
15
24
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
16
25
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
17
26
  const api_1 = require("@opentelemetry/api");
@@ -88,6 +97,14 @@ const chatSessionHandleKey = locals_js_1.locals.create("chat.sessionHandle");
88
97
  // is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
89
98
  // metadata reads this directly rather than the Session handle id.
90
99
  const chatExternalIdKey = locals_js_1.locals.create("chat.externalId");
100
+ /**
101
+ * Set at boot when a continuation run inherited an interrupted turn (a
102
+ * partial assistant reply on `session.out`). Consumed by the first channel
103
+ * turn's ack so the re-emitted placeholder reads as a recovery resume rather
104
+ * than a silent duplicate. Boxed so the ack site can flip it to false.
105
+ * @internal
106
+ */
107
+ const chatChannelRecoveryPendingKey = locals_js_1.locals.create("chat.channelRecoveryPending");
91
108
  /**
92
109
  * S2 seq_num of the most recent `turn-complete` control record written by
93
110
  * this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -379,14 +396,23 @@ function __setReplaySessionInTailImplForTests(impl) {
379
396
  * @internal
380
397
  */
381
398
  async function replaySessionInTail(sessionId, options) {
399
+ return (await replaySessionInTailWithChannels(sessionId, options)).messages;
400
+ }
401
+ /**
402
+ * The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
403
+ * seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
404
+ */
405
+ async function replaySessionInTailWithChannels(sessionId, options) {
382
406
  if (replaySessionInTailImpl) {
383
- return await replaySessionInTailImpl(sessionId, options);
407
+ const replayed = await replaySessionInTailImpl(sessionId, options);
408
+ return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
384
409
  }
385
410
  const apiClient = v3_1.apiClientManager.clientOrThrow();
386
411
  const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
387
412
  afterEventId: options?.lastEventId,
388
413
  });
389
414
  const out = [];
415
+ const channelEvents = [];
390
416
  for (const record of response.records) {
391
417
  // session.in writers POST `JSON.stringify(chunk)` directly; the
392
418
  // webapp wraps that in `{ data: <string>, id }` and stores it on
@@ -413,15 +439,23 @@ async function replaySessionInTail(sessionId, options) {
413
439
  if (!payload || payload.trigger !== "submit-message")
414
440
  continue;
415
441
  const message = payload.message;
416
- if (!message || typeof message !== "object")
442
+ if (!message || typeof message !== "object") {
443
+ if (payload.channelEvent && typeof payload.channelEvent === "object") {
444
+ channelEvents.push({
445
+ channelEvent: payload.channelEvent,
446
+ metadata: payload.metadata,
447
+ seqNum: record.seqNum,
448
+ });
449
+ }
417
450
  continue;
451
+ }
418
452
  out.push({
419
453
  message: message,
420
454
  metadata: payload.metadata,
421
455
  seqNum: record.seqNum,
422
456
  });
423
457
  }
424
- return out;
458
+ return { messages: out, channelEvents };
425
459
  }
426
460
  /**
427
461
  * Test-only entry point that bypasses
@@ -682,6 +716,37 @@ exports.ai = {
682
716
  function createChatAccessToken(taskId) {
683
717
  return auth_js_1.auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
684
718
  }
719
+ /**
720
+ * Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
721
+ *
722
+ * A session is addressed by `externalId`, so the same session is reachable from any surface that
723
+ * knows it: a web `useChat` client and a Slack thread converge on one session when they share an
724
+ * `externalId` (for a channel, the connector's `key` template produces it). This token carries
725
+ * `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
726
+ * stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
727
+ *
728
+ * Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
729
+ * (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
730
+ * a read+write token instead ({@link createChatStartSessionAction}).
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * // actions.ts
735
+ * "use server";
736
+ * import { chat } from "@trigger.dev/sdk/ai";
737
+ *
738
+ * export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
739
+ * ```
740
+ */
741
+ function createChatWatchToken(externalId, options) {
742
+ if (!externalId) {
743
+ throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
744
+ }
745
+ return auth_js_1.auth.createPublicToken({
746
+ scopes: { read: { sessions: externalId } },
747
+ expirationTime: options?.tokenTTL ?? "1h",
748
+ });
749
+ }
685
750
  function createChatOutGate() {
686
751
  let resolveOpened;
687
752
  const opened = new Promise((resolve) => {
@@ -1918,7 +1983,16 @@ const chatInstructionsConsumedKey = locals_js_1.locals.create("chat.injectedInst
1918
1983
  * @internal
1919
1984
  */
1920
1985
  const chatPipeCountKey = locals_js_1.locals.create("chat.pipeCount");
1986
+ /**
1987
+ * The current turn's channel observer tap, set before run() when the turn has channel observers
1988
+ * (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
1989
+ * manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
1990
+ * @internal
1991
+ */
1992
+ const chatTurnChannelTapKey = locals_js_1.locals.create("chat.turnChannelTap");
1921
1993
  const chatStopControllerKey = locals_js_1.locals.create("chat.stopController");
1994
+ const chatChannelSurfaceKey = locals_js_1.locals.create("chat.channelSurface");
1995
+ const chatChannelDownloadKey = locals_js_1.locals.create("chat.channelDownload");
1922
1996
  /** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
1923
1997
  const chatUIStreamStaticKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.static");
1924
1998
  /** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
@@ -2880,7 +2954,6 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
2880
2954
  // Span attributes describe the offered batch; the chunk and callback below
2881
2955
  // describe what was actually claimed and injected.
2882
2956
  const messageTexts = uiMessages.map(textOfUIMessage);
2883
- const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
2884
2957
  return tracer_js_1.tracer.startActiveSpan("pending message injected", async () => {
2885
2958
  /**
2886
2959
  * Claim before transforming, and inject only what was claimed.
@@ -3019,7 +3092,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
3019
3092
  [v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
3020
3093
  "pending.message_count": uiMessages.length,
3021
3094
  "pending.step_number": stepNumber,
3022
- "pending.messages": messageTexts,
3095
+ ...(ctx?.recordUserMessage === false ? {} : { "pending.messages": messageTexts }),
3023
3096
  ...(ctx?.chatId ? { "pending.chat_id": ctx.chatId } : {}),
3024
3097
  ...(ctx?.turn != null ? { "pending.turn": ctx.turn } : {}),
3025
3098
  ...(0, v3_1.accessoryAttributes)({
@@ -3284,7 +3357,7 @@ function buildManagedStreamTextOptions(options, config) {
3284
3357
  * caller's value winning because `rest` is spread after `managed`. Pulling a
3285
3358
  * key out to "handle" it is how a caller's option gets silently dropped.
3286
3359
  */
3287
- const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3360
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
3288
3361
  const managed = toStreamTextOptions({
3289
3362
  registry,
3290
3363
  system: callerSystem ?? agentSystem,
@@ -3327,6 +3400,12 @@ function buildManagedStreamTextOptions(options, config) {
3327
3400
  if (typeof managed.prepareStep === "function") {
3328
3401
  managed.prepareStep = (0, steeringContext_js_1.retainStepMessages)(managed.prepareStep);
3329
3402
  }
3403
+ if (typeof callerDownload === "function") {
3404
+ const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
3405
+ managed.experimental_download = channelDownload
3406
+ ? channelAwareDownload(channelDownload, callerDownload)
3407
+ : callerDownload;
3408
+ }
3330
3409
  return { ...managed, ...rest };
3331
3410
  }
3332
3411
  /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
@@ -3556,6 +3635,10 @@ function toStreamTextOptions(options, retain = true) {
3556
3635
  if (retain && typeof result.prepareStep === "function") {
3557
3636
  result.prepareStep = (0, steeringContext_js_1.retainStepMessages)(result.prepareStep);
3558
3637
  }
3638
+ const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
3639
+ if (channelDownload) {
3640
+ result.experimental_download = channelAwareDownload(channelDownload);
3641
+ }
3559
3642
  return result;
3560
3643
  }
3561
3644
  const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
@@ -3570,6 +3653,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3570
3653
  * tools, steering, compaction, injected instructions and persistence, with no
3571
3654
  * action-specific handling.
3572
3655
  *
3656
+ * Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
3657
+ * "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
3658
+ *
3573
3659
  * @example
3574
3660
  * ```ts
3575
3661
  * onAction: async ({ action }) => {
@@ -3577,12 +3663,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3577
3663
  * chat.history.slice(0, -1);
3578
3664
  * return chat.turn();
3579
3665
  * }
3666
+ * if (action.type === "ask") return chat.turn(action.question);
3580
3667
  * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3581
3668
  * },
3582
3669
  * ```
3583
3670
  */
3584
- function chatTurn() {
3585
- return { [actionTurnBrand]: true };
3671
+ function chatTurn(message) {
3672
+ return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
3586
3673
  }
3587
3674
  function isActionTurn(value) {
3588
3675
  return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
@@ -3648,6 +3735,21 @@ function warnMissingOnActionOnce() {
3648
3735
  function isAsyncIterable(value) {
3649
3736
  return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
3650
3737
  }
3738
+ function readableStreamFromAsyncIterable(source) {
3739
+ const iterator = source[Symbol.asyncIterator]();
3740
+ return new ReadableStream({
3741
+ async pull(controller) {
3742
+ const next = await iterator.next();
3743
+ if (next.done)
3744
+ controller.close();
3745
+ else
3746
+ controller.enqueue(next.value);
3747
+ },
3748
+ async cancel(reason) {
3749
+ await iterator.return?.(reason);
3750
+ },
3751
+ });
3752
+ }
3651
3753
  function isReadableStream(value) {
3652
3754
  return (typeof value === "object" && value !== null && typeof value.getReader === "function");
3653
3755
  }
@@ -3703,6 +3805,10 @@ async function pipeChat(source, options, capture) {
3703
3805
  throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
3704
3806
  "an AsyncIterable, or a ReadableStream");
3705
3807
  }
3808
+ const channelTap = locals_js_1.locals.get(chatTurnChannelTapKey);
3809
+ if (channelTap) {
3810
+ stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
3811
+ }
3706
3812
  const pipeOptions = {};
3707
3813
  if (options?.signal) {
3708
3814
  pipeOptions.signal = options.signal;
@@ -3732,155 +3838,1068 @@ async function pipeChat(source, options, capture) {
3732
3838
  await waitUntilComplete();
3733
3839
  }
3734
3840
  }
3735
- function chatCustomAgent(options) {
3736
- const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
3737
- const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3738
- const parseClientDataSync = clientDataSchema
3739
- ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
3740
- : undefined;
3741
- const task = (0, shared_js_1.createTask)({
3742
- ...restOptions,
3743
- triggerSource: "agent",
3744
- agentConfig: { type: "ai-sdk-chat" },
3745
- run: async (payload, runOptions) => {
3746
- // Bind the run to its backing Session so module-level helpers
3747
- // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
3748
- // resolve to this chat's `.in` / `.out` channels. Address
3749
- // everywhere by `payload.chatId` (the session externalId) so the
3750
- // agent's writes and the transport's reads converge on the same
3751
- // S2 stream key + waitpoint key.
3752
- //
3753
- // The Session row is created server-side by `POST /sessions` (or
3754
- // `chat.createStartSessionAction`) before this run is triggered.
3755
- // No client-side upsert needed.
3756
- locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
3757
- locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
3758
- locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
3759
- locals_js_1.locals.set(chatCustomAgentRunKey, true);
3760
- if (parseClientData && parseClientDataSync) {
3761
- locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
3762
- parse: parseClientData,
3763
- parseSync: parseClientDataSync,
3764
- });
3841
+ /** The AI SDK's default `toUIMessageStream` `onError`: the error's message, as the client sees it. */
3842
+ function uiStreamErrorText(error) {
3843
+ if (error == null)
3844
+ return "unknown error";
3845
+ if (typeof error === "string")
3846
+ return error;
3847
+ if (error instanceof Error)
3848
+ return error.message;
3849
+ return JSON.stringify(error);
3850
+ }
3851
+ /** The line a channel shows when a turn fails, unless the connector sets its own `errorMessage`. */
3852
+ exports.CHANNEL_TURN_ERROR_MESSAGE = "Something went wrong answering this. Try again.";
3853
+ async function channelTurnErrorMessage(connector, ctx) {
3854
+ const option = connector?.errorMessage;
3855
+ if (typeof option !== "function")
3856
+ return option || exports.CHANNEL_TURN_ERROR_MESSAGE;
3857
+ try {
3858
+ const line = await option({ ...ctx, defaultMessage: exports.CHANNEL_TURN_ERROR_MESSAGE });
3859
+ if (line)
3860
+ return line;
3861
+ }
3862
+ catch (error) {
3863
+ v3_1.logger.warn("chat.agent: channel errorMessage threw; posting the default line", { error });
3864
+ }
3865
+ return exports.CHANNEL_TURN_ERROR_MESSAGE;
3866
+ }
3867
+ /**
3868
+ * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
3869
+ * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
3870
+ * control. Use for providers without a preset, or to test the channel round-trip end to end.
3871
+ */
3872
+ function chatChannelCustom(options) {
3873
+ const { id, endpoint, key, inbound, bind, seed, outbound, ack, send, renderInteraction, onInteraction, finalizeInteraction, react, reactions, progress, surface, onControl, stream, endInterruptedStream, download, received, toAction, lifecycle, cancelInteraction, filter, startOn, delivery, errorMessage, } = options;
3874
+ if (!endpoint?.id) {
3875
+ throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
3876
+ }
3877
+ v3_1.resourceCatalog.registerDeclaredSessionWebhook(id);
3878
+ return {
3879
+ id,
3880
+ endpointId: endpoint.id,
3881
+ source: endpoint.source,
3882
+ key: (0, webhooks_js_1.normalizeKeyString)(key),
3883
+ filter,
3884
+ startOn,
3885
+ inbound,
3886
+ bind,
3887
+ seed,
3888
+ outbound,
3889
+ ack,
3890
+ send,
3891
+ renderInteraction,
3892
+ onInteraction,
3893
+ finalizeInteraction,
3894
+ react,
3895
+ reactions,
3896
+ progress,
3897
+ surface,
3898
+ onControl,
3899
+ stream,
3900
+ endInterruptedStream,
3901
+ download,
3902
+ received,
3903
+ toAction,
3904
+ lifecycle,
3905
+ cancelInteraction,
3906
+ delivery: delivery ?? "final",
3907
+ errorMessage,
3908
+ };
3909
+ }
3910
+ const chatChannels = {
3911
+ /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
3912
+ custom: chatChannelCustom,
3913
+ };
3914
+ // Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
3915
+ function channelSeedMessages(seed, provenance) {
3916
+ return seed
3917
+ .filter((m) => m && typeof m.text === "string" && m.text.length > 0)
3918
+ .map((m, index) => {
3919
+ const meta = {
3920
+ ...provenance,
3921
+ seeded: true,
3922
+ ...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
3923
+ ...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
3924
+ };
3925
+ return {
3926
+ id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
3927
+ role: m.role === "assistant" ? "assistant" : "user",
3928
+ parts: [{ type: "text", text: m.text }],
3929
+ metadata: meta,
3930
+ };
3931
+ });
3932
+ }
3933
+ // Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
3934
+ // it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
3935
+ /**
3936
+ * True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
3937
+ * data parts. A button click from another app on a shared endpoint maps to one.
3938
+ */
3939
+ function isEmptyChannelInbound(input) {
3940
+ const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
3941
+ ? input.message
3942
+ : input;
3943
+ if (typeof message === "string")
3944
+ return message.trim() === "";
3945
+ return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
3946
+ }
3947
+ /** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
3948
+ const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
3949
+ /**
3950
+ * One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
3951
+ * the session first, the next one after a failure) and caches what they return by URL, so a file in
3952
+ * the history is fetched once per run rather than per step.
3953
+ */
3954
+ function makeChannelDownloader(connectors, handleFor) {
3955
+ const downloaders = connectors.filter((c) => typeof c.download === "function");
3956
+ if (downloaders.length === 0)
3957
+ return undefined;
3958
+ const cache = new Map();
3959
+ const sizes = new Map();
3960
+ let cachedBytes = 0;
3961
+ const evict = () => {
3962
+ for (const key of cache.keys()) {
3963
+ if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
3964
+ return;
3965
+ cachedBytes -= sizes.get(key) ?? 0;
3966
+ sizes.delete(key);
3967
+ cache.delete(key);
3968
+ }
3969
+ };
3970
+ const fetchOwned = async (url) => {
3971
+ const candidates = downloaders.map((connector) => ({
3972
+ connector,
3973
+ handle: handleFor(connector.id),
3974
+ }));
3975
+ const ordered = [
3976
+ ...candidates.filter((c) => c.handle !== undefined),
3977
+ ...candidates.filter((c) => c.handle === undefined),
3978
+ ];
3979
+ let failure;
3980
+ for (const { connector, handle } of ordered) {
3981
+ try {
3982
+ const file = await connector.download(url, { handle });
3983
+ if (file)
3984
+ return file;
3765
3985
  }
3766
- if (onClientDataValidationError) {
3767
- locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
3986
+ catch (error) {
3987
+ failure ??= { error };
3768
3988
  }
3769
- locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
3770
- // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
3771
- // trims `session.out` back to the previous turn boundary. Without
3772
- // this the slot is undefined and the trim never runs, so `.out`
3773
- // grows without bound for the whole custom-agent surface.
3774
- locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
3775
- (0, streams_js_1.markChatAgentRunForStreamsWarning)();
3776
- v3_1.taskContext.setConversationId(payload.chatId);
3777
- stampConversationIdOnActiveSpan(payload.chatId);
3778
- await installChatInputRouter(payload.chatId, {
3779
- resuming: Boolean(payload.continuation),
3780
- });
3781
- // A custom agent's loop is the customer's, so there is no exit site the
3782
- // SDK controls. Perform a requested close when `run()` returns, whatever
3783
- // shape the loop had. Idempotent, so the createSession iterator having
3784
- // already closed on its own exit costs nothing.
3785
- const withClose = async (result) => {
3786
- try {
3787
- return await result;
3788
- }
3789
- finally {
3790
- await performChatClose();
3791
- }
3792
- };
3793
- // Keep the schema-free path identical to the original custom-agent
3794
- // wrapper, including when userRun starts executing.
3795
- if (!parseClientData) {
3796
- return withClose(userRun(payload, runOptions));
3989
+ }
3990
+ if (failure)
3991
+ throw failure.error;
3992
+ return null;
3993
+ };
3994
+ return (url) => {
3995
+ const key = url.href;
3996
+ const cached = cache.get(key);
3997
+ if (cached)
3998
+ return cached;
3999
+ const pending = fetchOwned(url).then((file) => {
4000
+ if (file && cache.get(key) === pending) {
4001
+ sizes.set(key, file.data.byteLength);
4002
+ cachedBytes += file.data.byteLength;
4003
+ evict();
3797
4004
  }
3798
- const isHandoverBoot = payload.trigger === "handover-prepare";
3799
- const isMessagelessBoot = payload.trigger === "preload" ||
3800
- (payload.continuation === true &&
3801
- payload.message === undefined &&
3802
- payload.trigger !== "action" &&
3803
- payload.trigger !== "regenerate-message" &&
3804
- !isHandoverBoot);
3805
- const validated = await validateChatCustomAgentPayload(payload, {
3806
- // Preload and continuation boots do not represent a submitted turn,
3807
- // so there is no sender waiting for a terminal frame. Handover errors
3808
- // must be written after the warm response flushes and signals below.
3809
- writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
4005
+ return file;
4006
+ }, (error) => {
4007
+ if (cache.get(key) === pending)
4008
+ cache.delete(key);
4009
+ throw error;
4010
+ });
4011
+ cache.set(key, pending);
4012
+ return pending;
4013
+ };
4014
+ }
4015
+ function fileUrlOf(part) {
4016
+ const p = part;
4017
+ const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
4018
+ if (raw instanceof URL)
4019
+ return raw;
4020
+ if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
4021
+ return undefined;
4022
+ try {
4023
+ return new URL(raw);
4024
+ }
4025
+ catch {
4026
+ return undefined;
4027
+ }
4028
+ }
4029
+ /**
4030
+ * Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
4031
+ * them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
4032
+ * copy of the messages, and the turn still runs. The transcript keeps the URL.
4033
+ */
4034
+ async function resolveChannelFiles(messages, download) {
4035
+ const failed = new Set();
4036
+ const mediaTypes = new Map();
4037
+ const urls = new Map();
4038
+ for (const message of messages) {
4039
+ if (!Array.isArray(message.content))
4040
+ continue;
4041
+ for (const part of message.content) {
4042
+ const url = fileUrlOf(part);
4043
+ if (url)
4044
+ urls.set(url.href, url);
4045
+ }
4046
+ }
4047
+ await Promise.all([...urls.values()].map(async (url) => {
4048
+ try {
4049
+ const file = await download(url);
4050
+ if (file?.mediaType)
4051
+ mediaTypes.set(url.href, file.mediaType);
4052
+ }
4053
+ catch (error) {
4054
+ failed.add(url.href);
4055
+ v3_1.logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
4056
+ url: url.origin + url.pathname,
4057
+ error,
3810
4058
  });
3811
- if (validated.ok) {
3812
- return withClose(userRun(validated.payload, runOptions));
3813
- }
3814
- if (isHandoverBoot) {
3815
- const signal = await waitForHandover({
3816
- payload,
3817
- timeout: "1h",
3818
- spanName: "waiting for handover signal (invalid clientData)",
3819
- });
3820
- if (!signal || signal.kind === "handover-skip") {
3821
- return;
3822
- }
3823
- // The head-start writer flushes before sending this signal. Writing
3824
- // the terminal error now preserves stream order and closes the stitch.
3825
- await writeChatCustomAgentClientDataErrorToStream(payload);
3826
- return;
4059
+ }
4060
+ }));
4061
+ const isWildcard = (part) => {
4062
+ const mediaType = part.mediaType;
4063
+ return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
4064
+ };
4065
+ return messages.map((message) => {
4066
+ if (!Array.isArray(message.content))
4067
+ return message;
4068
+ let changed = false;
4069
+ const content = message.content.map((part) => {
4070
+ const url = fileUrlOf(part);
4071
+ if (!url)
4072
+ return part;
4073
+ if (failed.has(url.href)) {
4074
+ changed = true;
4075
+ const name = part.filename;
4076
+ return {
4077
+ type: "text",
4078
+ text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
4079
+ };
3827
4080
  }
3828
- // The Session base payload is sticky across continuation runs. If it is
3829
- // invalid, returning here would boot the same bad metadata again on the
3830
- // next message. Stay attached and wait for a valid wire frame instead.
3831
- const next = await messagesInput.waitWithIdleTimeout({
3832
- idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
3833
- timeout: "1h",
3834
- spanName: "waiting for valid clientData",
3835
- });
3836
- if (!next.ok || next.output.trigger === "close") {
3837
- return;
4081
+ const downloaded = mediaTypes.get(url.href);
4082
+ if (downloaded && isWildcard(part)) {
4083
+ changed = true;
4084
+ return { ...part, mediaType: downloaded };
3838
4085
  }
3839
- // Normal input frames omit run-level boot context. Carry it forward so
3840
- // a continuation still tells the custom loop to restore prior state.
3841
- const recoveredPayload = {
3842
- ...next.output,
3843
- continuation: next.output.continuation ?? payload.continuation,
3844
- previousRunId: next.output.previousRunId ?? payload.previousRunId,
3845
- sessionId: next.output.sessionId ?? payload.sessionId,
3846
- idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3847
- };
3848
- return withClose(userRun(recoveredPayload, runOptions));
3849
- },
4086
+ return part;
4087
+ });
4088
+ return changed ? { ...message, content } : message;
3850
4089
  });
3851
- // Register clientDataSchema so the CLI converts it to JSONSchema
3852
- // and stores it as payloadSchema — used by the Playground UI
3853
- if (clientDataSchema) {
3854
- v3_1.resourceCatalog.updateTaskMetadata(options.id, {
3855
- schema: clientDataSchema,
4090
+ }
4091
+ /**
4092
+ * The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
4093
+ * every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
4094
+ * reads the URL itself, fetched from a public host otherwise).
4095
+ */
4096
+ function channelAwareDownload(download, fallback) {
4097
+ return async (requests) => {
4098
+ const results = await Promise.all(requests.map(async (request) => {
4099
+ const file = await download(request.url);
4100
+ return file ? { data: file.data, mediaType: file.mediaType } : null;
4101
+ }));
4102
+ const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
4103
+ if (misses.length === 0)
4104
+ return results;
4105
+ const rest = fallback
4106
+ ? await fallback(misses.map((i) => requests[i]))
4107
+ : await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : (0, modelFileDownload_js_1.fetchPublicFile)(requests[i].url)));
4108
+ misses.forEach((i, n) => {
4109
+ results[i] = rest[n] ?? null;
3856
4110
  });
4111
+ return results;
4112
+ };
4113
+ }
4114
+ /** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
4115
+ function channelClickAction(connectors, wire) {
4116
+ const channelEvent = wire;
4117
+ if (!channelEvent?.connectorId)
4118
+ return undefined;
4119
+ const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
4120
+ if (!connector?.toAction)
4121
+ return undefined;
4122
+ try {
4123
+ return connector.toAction(channelEvent.event) ?? undefined;
4124
+ }
4125
+ catch (error) {
4126
+ v3_1.logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
4127
+ error,
4128
+ });
4129
+ return undefined;
3857
4130
  }
3858
- return task;
3859
4131
  }
3860
- function chatAgent(options) {
3861
- const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
3862
- if (hydrateMessages) {
3863
- if (storage) {
3864
- throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3865
- "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3866
- "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3867
- }
3868
- if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
3869
- throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3870
- "`loadContext`. Both would own the model's context; keep one.");
3871
- }
3872
- warnHydrateMessagesDeprecatedOnce(options.id);
4132
+ /**
4133
+ * A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
4134
+ * the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
4135
+ * stop or other control, an interaction such as an approval click, an empty message, a connector
4136
+ * this agent does not list), which stays queued as its own turn.
4137
+ */
4138
+ function channelSteeringMessage(connectors, wire, messageId) {
4139
+ const connector = connectors?.find((c) => c.id === wire.connectorId);
4140
+ if (!connector)
4141
+ return undefined;
4142
+ try {
4143
+ if (connector.onControl?.(wire.event))
4144
+ return undefined;
4145
+ if (connector.onInteraction?.(wire.event))
4146
+ return undefined;
4147
+ if (connector.toAction?.(wire.event) != null)
4148
+ return undefined;
4149
+ const inbound = connector.inbound(wire.event);
4150
+ if (isEmptyChannelInbound(inbound))
4151
+ return undefined;
4152
+ const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
4153
+ source: "channel",
4154
+ connectorId: connector.id,
4155
+ provider: connector.source,
4156
+ deliveryId: wire.deliveryId,
4157
+ ...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
4158
+ });
4159
+ return { message };
3873
4160
  }
3874
- const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3875
- const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
3876
- // chat.agent does not expose generic retry options (see docstring on
3877
- // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
3878
- // `oomMachine` is set we allow one retry on a larger machine; otherwise
3879
- // we keep the historical no-retry default.
3880
- const retry = oomMachine
3881
- ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
3882
- : { maxAttempts: 1 };
3883
- const task = (0, shared_js_1.createTask)({
4161
+ catch (error) {
4162
+ v3_1.logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
4163
+ error,
4164
+ });
4165
+ return undefined;
4166
+ }
4167
+ }
4168
+ /**
4169
+ * Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
4170
+ * to answer (an interaction click, an action click, an empty message). Fire and forget.
4171
+ */
4172
+ function acknowledgeChannelMessage(connector, wire, handle) {
4173
+ if (!connector.received)
4174
+ return;
4175
+ try {
4176
+ if (connector.onInteraction?.(wire.event))
4177
+ return;
4178
+ if (connector.toAction?.(wire.event) != null)
4179
+ return;
4180
+ if (isEmptyChannelInbound(connector.inbound(wire.event)))
4181
+ return;
4182
+ void Promise.resolve(connector.received({
4183
+ event: wire.event,
4184
+ deliveryId: wire.deliveryId,
4185
+ handle,
4186
+ bound: false,
4187
+ })).catch(() => { });
4188
+ }
4189
+ catch {
4190
+ return;
4191
+ }
4192
+ }
4193
+ function channelUserMessage(input, messageId, provenance) {
4194
+ const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
4195
+ const message = (wrapped ? input.message : input);
4196
+ const extra = wrapped
4197
+ ? input
4198
+ : {};
4199
+ const meta = {
4200
+ ...provenance,
4201
+ ...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
4202
+ ...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
4203
+ };
4204
+ const base = typeof message === "string"
4205
+ ? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
4206
+ : message.id
4207
+ ? message
4208
+ : { ...message, id: messageId };
4209
+ const existing = base.metadata;
4210
+ return {
4211
+ ...base,
4212
+ metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
4213
+ };
4214
+ }
4215
+ /**
4216
+ * The assistant's closing text for a channel reply: the text after the last tool part in the message,
4217
+ * so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
4218
+ * is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
4219
+ * tool parts (an ordinary turn), so a plain reply is unchanged.
4220
+ */
4221
+ function channelReplyText(message) {
4222
+ const parts = (message.parts ?? []);
4223
+ const isTool = (p) => {
4224
+ const t = p?.type;
4225
+ return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
4226
+ };
4227
+ let lastToolIdx = -1;
4228
+ parts.forEach((p, i) => {
4229
+ if (isTool(p))
4230
+ lastToolIdx = i;
4231
+ });
4232
+ const textFrom = (from) => parts
4233
+ .slice(from)
4234
+ .map((p) => p && typeof p === "object" && p.type === "text"
4235
+ ? String(p.text)
4236
+ : "")
4237
+ .join("");
4238
+ const trailing = textFrom(lastToolIdx + 1);
4239
+ return trailing.trim().length > 0 ? trailing : textFrom(0);
4240
+ }
4241
+ /**
4242
+ * Tries at a turn's final channel reply before it is given up on and the turn shows the error
4243
+ * reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
4244
+ * a post is tried once.
4245
+ */
4246
+ const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
4247
+ /** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
4248
+ function pendingToolCallsInMessage(message) {
4249
+ const out = [];
4250
+ for (const part of (message.parts ?? [])) {
4251
+ if (!part || typeof part !== "object")
4252
+ continue;
4253
+ const type = part.type;
4254
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4255
+ if (!isTool || part.state !== "input-available")
4256
+ continue;
4257
+ const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
4258
+ if (typeof part.toolCallId === "string")
4259
+ out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
4260
+ }
4261
+ return out;
4262
+ }
4263
+ /**
4264
+ * Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
4265
+ * that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
4266
+ * undefined when no pending call of that tool exists.
4267
+ */
4268
+ function resolveInteractionToolCallId(resolution, messages) {
4269
+ if (resolution.toolCallId) {
4270
+ return resolution;
4271
+ }
4272
+ for (let i = messages.length - 1; i >= 0; i--) {
4273
+ const message = messages[i];
4274
+ if (message.role !== "assistant")
4275
+ continue;
4276
+ for (const call of pendingToolCallsInMessage(message).reverse()) {
4277
+ if (call.toolName === resolution.toolName) {
4278
+ return { ...resolution, toolCallId: call.toolCallId };
4279
+ }
4280
+ }
4281
+ }
4282
+ return undefined;
4283
+ }
4284
+ /**
4285
+ * Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
4286
+ * the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
4287
+ * the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
4288
+ */
4289
+ function buildInteractionResolutionMessage(resolution, messages, provenance) {
4290
+ // Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
4291
+ // one assistant message, and a second click on a decided call must not resolve a stale copy.
4292
+ for (const message of messages) {
4293
+ if (message.role !== "assistant")
4294
+ continue;
4295
+ for (const part of (message.parts ?? [])) {
4296
+ if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
4297
+ if (isResolvedToolState(part.state))
4298
+ return undefined;
4299
+ }
4300
+ }
4301
+ }
4302
+ for (let i = messages.length - 1; i >= 0; i--) {
4303
+ const message = messages[i];
4304
+ if (message.role !== "assistant")
4305
+ continue;
4306
+ for (const part of (message.parts ?? [])) {
4307
+ if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
4308
+ continue;
4309
+ const type = part.type;
4310
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4311
+ if (!isTool)
4312
+ continue;
4313
+ if (part.state !== "input-available")
4314
+ return undefined;
4315
+ const slimPart = {
4316
+ type,
4317
+ toolCallId: resolution.toolCallId,
4318
+ state: "output-available",
4319
+ output: resolution.output,
4320
+ };
4321
+ if (type === "dynamic-tool" && typeof part.toolName === "string")
4322
+ slimPart.toolName = part.toolName;
4323
+ return {
4324
+ id: message.id,
4325
+ role: "assistant",
4326
+ parts: [slimPart],
4327
+ ...(provenance ? { metadata: provenance } : {}),
4328
+ };
4329
+ }
4330
+ }
4331
+ return undefined;
4332
+ }
4333
+ // Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
4334
+ function defaultChannelOutbound(reply) {
4335
+ return reply.text ? { text: reply.text } : null;
4336
+ }
4337
+ // Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
4338
+ // pick one at random from an array. Returns undefined to skip (no choice / empty).
4339
+ async function resolveReactionChoice(choice, event) {
4340
+ if (choice == null)
4341
+ return undefined;
4342
+ let value = typeof choice === "function" ? await choice(event) : choice;
4343
+ if (Array.isArray(value)) {
4344
+ if (value.length === 0)
4345
+ return undefined;
4346
+ value = value[Math.floor(Math.random() * value.length)];
4347
+ }
4348
+ return typeof value === "string" && value.length > 0 ? value : undefined;
4349
+ }
4350
+ // Best-effort reaction on the triggering message; a failure never breaks the turn.
4351
+ async function applyChannelReaction(connector, wireEvent, reaction) {
4352
+ if (!connector?.react || !wireEvent || !reaction.name)
4353
+ return;
4354
+ try {
4355
+ await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
4356
+ }
4357
+ catch (error) {
4358
+ v3_1.logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
4359
+ }
4360
+ }
4361
+ // The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
4362
+ // `react`, so `payload.channel` is only present when reacting is actually possible.
4363
+ function buildChannelRunSurface(connector, wireEvent, handle) {
4364
+ if (!connector)
4365
+ return undefined;
4366
+ const canReact = !!connector.react && !!wireEvent;
4367
+ if (!canReact && !connector.surface)
4368
+ return undefined;
4369
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4370
+ let extra = {};
4371
+ if (connector.surface) {
4372
+ try {
4373
+ extra = connector.surface(turnCtx) ?? {};
4374
+ }
4375
+ catch (error) {
4376
+ v3_1.logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
4377
+ }
4378
+ }
4379
+ const noop = async () => { };
4380
+ return {
4381
+ ...extra,
4382
+ react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
4383
+ unreact: canReact
4384
+ ? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
4385
+ : noop,
4386
+ };
4387
+ }
4388
+ function channelTurnCtx(wireEvent, handle) {
4389
+ return {
4390
+ ...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
4391
+ ...(handle !== undefined ? { handle } : {}),
4392
+ bound: !wireEvent,
4393
+ };
4394
+ }
4395
+ /**
4396
+ * Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
4397
+ * then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
4398
+ * `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
4399
+ * lose the results behind it, and nothing new lands after the turn ended.
4400
+ */
4401
+ function makeChannelProgressObserver(connector, wireEvent, handle) {
4402
+ const progress = connector.progress;
4403
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4404
+ const toolNames = new Map();
4405
+ const toolTitles = new Map();
4406
+ let chain = Promise.resolve();
4407
+ let stopped = false;
4408
+ const emit = (event) => {
4409
+ chain = chain.then(async () => {
4410
+ try {
4411
+ await progress(event, turnCtx);
4412
+ }
4413
+ catch (error) {
4414
+ v3_1.logger.warn("chat.agent: channel progress() failed; continuing", {
4415
+ error,
4416
+ type: event.type,
4417
+ });
4418
+ }
4419
+ });
4420
+ };
4421
+ return {
4422
+ observe(chunk) {
4423
+ if (stopped)
4424
+ return;
4425
+ const c = chunk;
4426
+ if (!c?.type || !c.toolCallId)
4427
+ return;
4428
+ if (c.title)
4429
+ toolTitles.set(c.toolCallId, c.title);
4430
+ const title = toolTitles.get(c.toolCallId);
4431
+ const titled = title ? { title } : {};
4432
+ if (c.type === "tool-input-start" && c.toolName) {
4433
+ toolNames.set(c.toolCallId, c.toolName);
4434
+ return;
4435
+ }
4436
+ if (c.type === "tool-input-available") {
4437
+ const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
4438
+ toolNames.set(c.toolCallId, toolName);
4439
+ emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, ...titled, input: c.input });
4440
+ return;
4441
+ }
4442
+ if (c.type === "tool-output-available" && !c.preliminary) {
4443
+ emit({
4444
+ type: "tool-result",
4445
+ toolCallId: c.toolCallId,
4446
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4447
+ ...titled,
4448
+ output: c.output,
4449
+ });
4450
+ return;
4451
+ }
4452
+ if (c.type === "tool-output-error") {
4453
+ emit({
4454
+ type: "tool-error",
4455
+ toolCallId: c.toolCallId,
4456
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4457
+ ...titled,
4458
+ errorText: c.errorText ?? "tool failed",
4459
+ });
4460
+ }
4461
+ },
4462
+ async stop() {
4463
+ stopped = true;
4464
+ await chain;
4465
+ },
4466
+ };
4467
+ }
4468
+ /** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
4469
+ const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
4470
+ /**
4471
+ * Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
4472
+ * while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
4473
+ * `working` is still the latest report, so a refresh can never land after the turn went idle.
4474
+ */
4475
+ function makeChannelLifecycleReporter() {
4476
+ let chain = Promise.resolve();
4477
+ let seq = 0;
4478
+ let refreshTimer;
4479
+ let closed = false;
4480
+ const clearRefresh = () => {
4481
+ if (refreshTimer)
4482
+ clearTimeout(refreshTimer);
4483
+ refreshTimer = undefined;
4484
+ };
4485
+ const enqueue = (connector, state, ctx, reportSeq) => {
4486
+ chain = chain.then(async () => {
4487
+ if (ctx.refresh && reportSeq !== seq)
4488
+ return;
4489
+ try {
4490
+ await connector.lifecycle(state, ctx);
4491
+ }
4492
+ catch (error) {
4493
+ v3_1.logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
4494
+ }
4495
+ });
4496
+ return chain;
4497
+ };
4498
+ return {
4499
+ report(connector, state, ctx) {
4500
+ if (!connector?.lifecycle || closed)
4501
+ return Promise.resolve();
4502
+ if (state === "closed")
4503
+ closed = true;
4504
+ const reportSeq = ++seq;
4505
+ clearRefresh();
4506
+ const done = enqueue(connector, state, ctx, reportSeq);
4507
+ if (state === "working") {
4508
+ const arm = () => {
4509
+ refreshTimer = setTimeout(() => {
4510
+ if (reportSeq !== seq)
4511
+ return;
4512
+ void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
4513
+ arm();
4514
+ }, CHANNEL_WORKING_REFRESH_MS);
4515
+ refreshTimer.unref?.();
4516
+ };
4517
+ arm();
4518
+ }
4519
+ return done;
4520
+ },
4521
+ dispose: clearRefresh,
4522
+ };
4523
+ }
4524
+ /**
4525
+ * The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
4526
+ * a stop that arrives while the session waits on a human decision. Undefined when none matched.
4527
+ */
4528
+ function stopPendingToolCalls(message, toolCallIds, errorText) {
4529
+ let changed = false;
4530
+ const parts = (message.parts ?? []).map((part) => {
4531
+ if (part &&
4532
+ typeof part === "object" &&
4533
+ toolCallIds.has(part.toolCallId) &&
4534
+ part.state === "input-available") {
4535
+ changed = true;
4536
+ return { ...part, state: "output-error", errorText };
4537
+ }
4538
+ return part;
4539
+ });
4540
+ return changed ? { ...message, parts } : undefined;
4541
+ }
4542
+ /**
4543
+ * Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
4544
+ * tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
4545
+ * ends the stream itself with the turn's outcome.
4546
+ */
4547
+ function makeChannelReplyStreamObserver(stream) {
4548
+ const toolNames = new Map();
4549
+ const toolTitles = new Map();
4550
+ let stopped = false;
4551
+ const safe = (fn) => {
4552
+ try {
4553
+ fn();
4554
+ }
4555
+ catch (error) {
4556
+ v3_1.logger.warn("chat.agent: channel reply stream threw; continuing", { error });
4557
+ }
4558
+ };
4559
+ return {
4560
+ observe(chunk) {
4561
+ if (stopped)
4562
+ return;
4563
+ const c = chunk;
4564
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4565
+ const delta = c.delta;
4566
+ safe(() => stream.text(delta));
4567
+ return;
4568
+ }
4569
+ if (!stream.progress || !c?.type || !c.toolCallId)
4570
+ return;
4571
+ const toolCallId = c.toolCallId;
4572
+ if (c.title)
4573
+ toolTitles.set(toolCallId, c.title);
4574
+ const title = toolTitles.get(toolCallId);
4575
+ const titled = title ? { title } : {};
4576
+ if (c.type === "tool-input-start" && c.toolName) {
4577
+ toolNames.set(toolCallId, c.toolName);
4578
+ }
4579
+ else if (c.type === "tool-input-available") {
4580
+ const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
4581
+ toolNames.set(toolCallId, toolName);
4582
+ safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, ...titled, input: c.input }));
4583
+ }
4584
+ else if (c.type === "tool-output-available" && !c.preliminary) {
4585
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4586
+ safe(() => stream.progress({
4587
+ type: "tool-result",
4588
+ toolCallId,
4589
+ toolName,
4590
+ ...titled,
4591
+ output: c.output,
4592
+ }));
4593
+ }
4594
+ else if (c.type === "tool-output-error") {
4595
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4596
+ const errorText = c.errorText ?? "tool failed";
4597
+ safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, ...titled, errorText }));
4598
+ }
4599
+ },
4600
+ stop() {
4601
+ stopped = true;
4602
+ },
4603
+ };
4604
+ }
4605
+ function composeChannelObservers(observers) {
4606
+ return {
4607
+ observe(chunk) {
4608
+ for (const o of observers)
4609
+ o.observe(chunk);
4610
+ },
4611
+ async stop() {
4612
+ await Promise.all(observers.map((o) => o.stop()));
4613
+ },
4614
+ };
4615
+ }
4616
+ // "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
4617
+ // text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
4618
+ // the authoritative last write. Best-effort: an edit failure is logged, never fatal.
4619
+ const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
4620
+ function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
4621
+ const outbound = connector.outbound ?? defaultChannelOutbound;
4622
+ let latest = "";
4623
+ let lastSent = "";
4624
+ let timer;
4625
+ let inFlightPromise;
4626
+ let stopped = false;
4627
+ const arm = () => {
4628
+ if (stopped || timer)
4629
+ return;
4630
+ timer = setTimeout(() => {
4631
+ timer = undefined;
4632
+ void edit();
4633
+ }, CHANNEL_STREAM_EDIT_INTERVAL_MS);
4634
+ };
4635
+ const edit = async () => {
4636
+ if (stopped || inFlightPromise)
4637
+ return;
4638
+ const text = latest;
4639
+ if (text === lastSent)
4640
+ return;
4641
+ const message = outbound({
4642
+ text,
4643
+ message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
4644
+ final: false,
4645
+ stopped: false,
4646
+ });
4647
+ if (!message)
4648
+ return;
4649
+ inFlightPromise = (async () => {
4650
+ try {
4651
+ await connector.send(message, {
4652
+ event: channelEvent.event,
4653
+ handle,
4654
+ deliveryId: channelEvent.deliveryId,
4655
+ previousRef: ackRef,
4656
+ mode: "stream",
4657
+ final: false,
4658
+ bound: false,
4659
+ });
4660
+ lastSent = text;
4661
+ }
4662
+ catch (error) {
4663
+ v3_1.logger.warn("chat.agent: channel stream edit failed", { error });
4664
+ }
4665
+ finally {
4666
+ inFlightPromise = undefined;
4667
+ if (!stopped && latest !== lastSent)
4668
+ arm();
4669
+ }
4670
+ })();
4671
+ await inFlightPromise;
4672
+ };
4673
+ return {
4674
+ observe(chunk) {
4675
+ if (stopped)
4676
+ return;
4677
+ const c = chunk;
4678
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4679
+ latest += c.delta;
4680
+ arm();
4681
+ }
4682
+ },
4683
+ async stop() {
4684
+ stopped = true;
4685
+ if (timer) {
4686
+ clearTimeout(timer);
4687
+ timer = undefined;
4688
+ }
4689
+ if (inFlightPromise) {
4690
+ try {
4691
+ await inFlightPromise;
4692
+ }
4693
+ catch { }
4694
+ }
4695
+ },
4696
+ };
4697
+ }
4698
+ /**
4699
+ * Wrap the reply stream so it debounce-edits the channel message as it streams.
4700
+ * Both `flush` (the stream completed) and `cancel` (the stream was aborted or
4701
+ * cancelled downstream) stop the editor, so a pending debounce timer can never
4702
+ * fire an edit after the turn ended, onto a message the next turn already owns.
4703
+ */
4704
+ /**
4705
+ * Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
4706
+ * is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
4707
+ * the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
4708
+ * stops it.
4709
+ */
4710
+ function makeChannelStreamTap(editor, options = {}) {
4711
+ const stopOnEnd = options.stopOnEnd ?? true;
4712
+ const transformer = {
4713
+ transform(chunk, controller) {
4714
+ editor.observe(chunk);
4715
+ controller.enqueue(chunk);
4716
+ },
4717
+ async flush() {
4718
+ if (stopOnEnd)
4719
+ await editor.stop();
4720
+ },
4721
+ async cancel() {
4722
+ if (stopOnEnd)
4723
+ await editor.stop();
4724
+ },
4725
+ };
4726
+ return new TransformStream(transformer);
4727
+ }
4728
+ /**
4729
+ * Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
4730
+ * final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
4731
+ * settles it once that egress has finished, so a caller that saw the chunk can wait for the
4732
+ * connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
4733
+ * no open latch is quiet.
4734
+ */
4735
+ const channelEgressLatches = new Map();
4736
+ function openChannelEgressLatch(chatId) {
4737
+ let settle;
4738
+ const promise = new Promise((resolve) => (settle = resolve));
4739
+ channelEgressLatches.set(chatId, { promise, settle });
4740
+ }
4741
+ function settleChannelEgressLatch(chatId) {
4742
+ const latch = channelEgressLatches.get(chatId);
4743
+ if (!latch)
4744
+ return;
4745
+ channelEgressLatches.delete(chatId);
4746
+ latch.settle();
4747
+ }
4748
+ /** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
4749
+ async function __waitForChannelEgressForTests(chatId) {
4750
+ await channelEgressLatches.get(chatId)?.promise;
4751
+ }
4752
+ function chatCustomAgent(options) {
4753
+ const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
4754
+ const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
4755
+ const parseClientDataSync = clientDataSchema
4756
+ ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
4757
+ : undefined;
4758
+ const task = (0, shared_js_1.createTask)({
4759
+ ...restOptions,
4760
+ triggerSource: "agent",
4761
+ agentConfig: { type: "ai-sdk-chat" },
4762
+ run: async (payload, runOptions) => {
4763
+ // Bind the run to its backing Session so module-level helpers
4764
+ // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
4765
+ // resolve to this chat's `.in` / `.out` channels. Address
4766
+ // everywhere by `payload.chatId` (the session externalId) so the
4767
+ // agent's writes and the transport's reads converge on the same
4768
+ // S2 stream key + waitpoint key.
4769
+ //
4770
+ // The Session row is created server-side by `POST /sessions` (or
4771
+ // `chat.createStartSessionAction`) before this run is triggered.
4772
+ // No client-side upsert needed.
4773
+ locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
4774
+ locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
4775
+ locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
4776
+ locals_js_1.locals.set(chatCustomAgentRunKey, true);
4777
+ if (parseClientData && parseClientDataSync) {
4778
+ locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
4779
+ parse: parseClientData,
4780
+ parseSync: parseClientDataSync,
4781
+ });
4782
+ }
4783
+ if (onClientDataValidationError) {
4784
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
4785
+ }
4786
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
4787
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
4788
+ // trims `session.out` back to the previous turn boundary. Without
4789
+ // this the slot is undefined and the trim never runs, so `.out`
4790
+ // grows without bound for the whole custom-agent surface.
4791
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
4792
+ (0, streams_js_1.markChatAgentRunForStreamsWarning)();
4793
+ v3_1.taskContext.setConversationId(payload.chatId);
4794
+ stampConversationIdOnActiveSpan(payload.chatId);
4795
+ await installChatInputRouter(payload.chatId, {
4796
+ resuming: Boolean(payload.continuation),
4797
+ });
4798
+ // A custom agent's loop is the customer's, so there is no exit site the
4799
+ // SDK controls. Perform a requested close when `run()` returns, whatever
4800
+ // shape the loop had. Idempotent, so the createSession iterator having
4801
+ // already closed on its own exit costs nothing.
4802
+ const withClose = async (result) => {
4803
+ try {
4804
+ return await result;
4805
+ }
4806
+ finally {
4807
+ await performChatClose();
4808
+ }
4809
+ };
4810
+ // Keep the schema-free path identical to the original custom-agent
4811
+ // wrapper, including when userRun starts executing.
4812
+ if (!parseClientData) {
4813
+ return withClose(userRun(payload, runOptions));
4814
+ }
4815
+ const isHandoverBoot = payload.trigger === "handover-prepare";
4816
+ const isMessagelessBoot = payload.trigger === "preload" ||
4817
+ (payload.continuation === true &&
4818
+ payload.message === undefined &&
4819
+ payload.trigger !== "action" &&
4820
+ payload.trigger !== "regenerate-message" &&
4821
+ !isHandoverBoot);
4822
+ const validated = await validateChatCustomAgentPayload(payload, {
4823
+ // Preload and continuation boots do not represent a submitted turn,
4824
+ // so there is no sender waiting for a terminal frame. Handover errors
4825
+ // must be written after the warm response flushes and signals below.
4826
+ writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
4827
+ });
4828
+ if (validated.ok) {
4829
+ return withClose(userRun(validated.payload, runOptions));
4830
+ }
4831
+ if (isHandoverBoot) {
4832
+ const signal = await waitForHandover({
4833
+ payload,
4834
+ timeout: "1h",
4835
+ spanName: "waiting for handover signal (invalid clientData)",
4836
+ });
4837
+ if (!signal || signal.kind === "handover-skip") {
4838
+ return;
4839
+ }
4840
+ // The head-start writer flushes before sending this signal. Writing
4841
+ // the terminal error now preserves stream order and closes the stitch.
4842
+ await writeChatCustomAgentClientDataErrorToStream(payload);
4843
+ return;
4844
+ }
4845
+ // The Session base payload is sticky across continuation runs. If it is
4846
+ // invalid, returning here would boot the same bad metadata again on the
4847
+ // next message. Stay attached and wait for a valid wire frame instead.
4848
+ const next = await messagesInput.waitWithIdleTimeout({
4849
+ idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
4850
+ timeout: "1h",
4851
+ spanName: "waiting for valid clientData",
4852
+ });
4853
+ if (!next.ok || next.output.trigger === "close") {
4854
+ return;
4855
+ }
4856
+ // Normal input frames omit run-level boot context. Carry it forward so
4857
+ // a continuation still tells the custom loop to restore prior state.
4858
+ const recoveredPayload = {
4859
+ ...next.output,
4860
+ continuation: next.output.continuation ?? payload.continuation,
4861
+ previousRunId: next.output.previousRunId ?? payload.previousRunId,
4862
+ sessionId: next.output.sessionId ?? payload.sessionId,
4863
+ idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
4864
+ };
4865
+ return withClose(userRun(recoveredPayload, runOptions));
4866
+ },
4867
+ });
4868
+ // Register clientDataSchema so the CLI converts it to JSONSchema
4869
+ // and stores it as payloadSchema — used by the Playground UI
4870
+ if (clientDataSchema) {
4871
+ v3_1.resourceCatalog.updateTaskMetadata(options.id, {
4872
+ schema: clientDataSchema,
4873
+ });
4874
+ }
4875
+ return task;
4876
+ }
4877
+ function chatAgent(options) {
4878
+ const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, events, channels, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, telemetry: telemetryOptions, ...restOptions } = options;
4879
+ const recordUserMessage = telemetryOptions?.recordUserMessage ?? true;
4880
+ const recordClientData = telemetryOptions?.recordClientData ?? true;
4881
+ if (hydrateMessages) {
4882
+ if (storage) {
4883
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
4884
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
4885
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
4886
+ }
4887
+ if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
4888
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
4889
+ "`loadContext`. Both would own the model's context; keep one.");
4890
+ }
4891
+ warnHydrateMessagesDeprecatedOnce(options.id);
4892
+ }
4893
+ const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
4894
+ const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
4895
+ // chat.agent does not expose generic retry options (see docstring on
4896
+ // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
4897
+ // `oomMachine` is set we allow one retry on a larger machine; otherwise
4898
+ // we keep the historical no-retry default.
4899
+ const retry = oomMachine
4900
+ ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
4901
+ : { maxAttempts: 1 };
4902
+ const task = (0, shared_js_1.createTask)({
3884
4903
  ...restOptions,
3885
4904
  retry,
3886
4905
  triggerSource: "agent",
@@ -4024,6 +5043,69 @@ function chatAgent(options) {
4024
5043
  : undefined;
4025
5044
  let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
4026
5045
  let bootTranscriptState = null;
5046
+ /**
5047
+ * The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
5048
+ * whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
5049
+ */
5050
+ let sessionChannelBinding;
5051
+ const channelDownloader = channels
5052
+ ? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
5053
+ ? sessionChannelBinding.handle
5054
+ : undefined)
5055
+ : undefined;
5056
+ locals_js_1.locals.set(chatChannelDownloadKey, channelDownloader);
5057
+ /**
5058
+ * Tool calls that already have channel controls posted. A turn that resumes with one of
5059
+ * several pending calls resolved still carries the others as pending; without this the
5060
+ * controls would be posted again, and the model's final text would lose to them. A
5061
+ * continuation worker seeds it at boot from the calls still pending in the restored
5062
+ * transcript of a channel-bound session, since the worker before it posted their controls.
5063
+ */
5064
+ const channelRenderedToolCallIds = new Set();
5065
+ /** Serializes `lifecycle()` reports for the session's channel across turns. */
5066
+ const channelLifecycle = makeChannelLifecycleReporter();
5067
+ /**
5068
+ * Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
5069
+ * is dropped even when the transcript was hydrated again from a store that never saw the stop.
5070
+ */
5071
+ const channelStoppedToolCallIds = new Set();
5072
+ /** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
5073
+ let lastChannelTurnStartedAt;
5074
+ /** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
5075
+ let lastChannelTurnEndedAt;
5076
+ /** The turn reported its end state: the binding no longer marks the surface as working. */
5077
+ const clearChannelWorking = () => {
5078
+ if (!sessionChannelBinding?.working)
5079
+ return;
5080
+ const { working: _working, ...rest } = sessionChannelBinding;
5081
+ void _working;
5082
+ sessionChannelBinding = rest;
5083
+ };
5084
+ /** The turn's reply stream ended: the binding no longer names it for recovery. */
5085
+ const clearOpenReplyStream = () => {
5086
+ if (!sessionChannelBinding?.stream)
5087
+ return;
5088
+ const { stream: _stream, ...rest } = sessionChannelBinding;
5089
+ void _stream;
5090
+ sessionChannelBinding = rest;
5091
+ };
5092
+ /** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
5093
+ const reportChannelClosed = async () => {
5094
+ const binding = sessionChannelBinding;
5095
+ if (!binding)
5096
+ return;
5097
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5098
+ await channelLifecycle.report(connector, "closed", {
5099
+ handle: binding.handle,
5100
+ bound: true,
5101
+ firstTurn: false,
5102
+ });
5103
+ };
5104
+ /** Provenance of the turn or action being processed, for the storage context. */
5105
+ let currentTurnSource = "client";
5106
+ let currentTurnConnectorId;
5107
+ /** True once `onAction` returned `chat.turn()` for the action being processed. */
5108
+ let turnIsActionTurn = false;
4027
5109
  /**
4028
5110
  * True while the model lane holds a compaction summary, so it cannot be
4029
5111
  * rebuilt from the transcript and has to be persisted as state. Reset
@@ -4086,9 +5168,14 @@ function chatAgent(options) {
4086
5168
  const transcriptIds = new Set(opts.messages.map((message) => message.id));
4087
5169
  const markerIds = new Set((0, steeringContext_js_1.steeringMarkers)(opts.messages).map((marker) => marker.id));
4088
5170
  const steering = [...(locals_js_1.locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
4089
- const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0 || steering.length > 0
5171
+ const runtimeState = laneCompacted ||
5172
+ laneInjections.length > 0 ||
5173
+ queued.length > 0 ||
5174
+ steering.length > 0 ||
5175
+ sessionChannelBinding
4090
5176
  ? {
4091
5177
  v: 1,
5178
+ ...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
4092
5179
  ...(laneCompacted
4093
5180
  ? {
4094
5181
  compaction: {
@@ -4118,7 +5205,9 @@ function chatAgent(options) {
4118
5205
  chatId: payload.chatId,
4119
5206
  clientData: opts.clientData,
4120
5207
  turn: opts.turn,
4121
- trigger: opts.trigger,
5208
+ trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
5209
+ source: currentTurnSource,
5210
+ ...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
4122
5211
  runId: ctx.run.id,
4123
5212
  ctx,
4124
5213
  }, {
@@ -4201,6 +5290,9 @@ function chatAgent(options) {
4201
5290
  let replayedPartial;
4202
5291
  let replayedPartialRaw;
4203
5292
  let replayedInTail = [];
5293
+ // Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
5294
+ // they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
5295
+ let replayedChannelInTail = [];
4204
5296
  // Wire payloads to dispatch as turns before the regular session.in
4205
5297
  // pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
4206
5298
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
@@ -4331,9 +5423,11 @@ function chatAgent(options) {
4331
5423
  }
4332
5424
  bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
4333
5425
  try {
4334
- replayedInTail = await replaySessionInTail(payload.chatId, {
5426
+ const tail = await replaySessionInTailWithChannels(payload.chatId, {
4335
5427
  lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
4336
5428
  });
5429
+ replayedInTail = tail.messages;
5430
+ replayedChannelInTail = tail.channelEvents;
4337
5431
  }
4338
5432
  catch (error) {
4339
5433
  v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
@@ -4354,7 +5448,10 @@ function chatAgent(options) {
4354
5448
  }
4355
5449
  // ── session.in router ──────────────────────────────────────────
4356
5450
  //
4357
- const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
5451
+ const recoveredSeqNums = [
5452
+ ...replayedInTail.map((r) => r.seqNum),
5453
+ ...replayedChannelInTail.map((r) => r.seqNum),
5454
+ ];
4358
5455
  await installChatInputRouter(payload.chatId, {
4359
5456
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4360
5457
  recoveredSeqNums,
@@ -4371,6 +5468,9 @@ function chatAgent(options) {
4371
5468
  // predecessor chose to end before processing the message;
4372
5469
  // those route through the normal continuation-wait path.
4373
5470
  const hasRecoveredState = partialAssistant !== undefined;
5471
+ if (couldHavePriorState && hasRecoveredState) {
5472
+ locals_js_1.locals.set(chatChannelRecoveryPendingKey, { value: true });
5473
+ }
4374
5474
  let hookChain;
4375
5475
  let hookRecoveredTurns;
4376
5476
  let hookBeforeBoot;
@@ -4441,10 +5541,18 @@ function chatAgent(options) {
4441
5541
  // user is re-dispatched as a fresh turn instead.
4442
5542
  let seedChain;
4443
5543
  let recoveredEntries;
5544
+ // The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
5545
+ // which only makes sense when that record is the browser message it was answering. When a
5546
+ // channel delivery is older than every browser message, the partial belongs to it instead, and
5547
+ // splicing the first browser message ahead of it would reorder the conversation.
5548
+ const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
5549
+ const partialFollowsBrowserMessage = partialAssistant !== undefined &&
5550
+ inFlightUsers.length > 1 &&
5551
+ (replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
4444
5552
  if (hookChain !== undefined) {
4445
5553
  seedChain = hookChain;
4446
5554
  }
4447
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5555
+ else if (partialFollowsBrowserMessage) {
4448
5556
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
4449
5557
  }
4450
5558
  else {
@@ -4464,7 +5572,7 @@ function chatAgent(options) {
4464
5572
  seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4465
5573
  }));
4466
5574
  }
4467
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5575
+ else if (partialFollowsBrowserMessage) {
4468
5576
  recoveredEntries = replayedInTail
4469
5577
  .slice(1)
4470
5578
  .map((r) => ({ message: r.message, seqNum: r.seqNum }));
@@ -4501,6 +5609,7 @@ function chatAgent(options) {
4501
5609
  metadataById.set(entry.message.id, entry.metadata);
4502
5610
  }
4503
5611
  const dispatchedRecoveredSeqs = new Set();
5612
+ const toInject = [];
4504
5613
  for (const { message: msg, seqNum } of recoveredEntries) {
4505
5614
  if (wireMessageId && msg.id === wireMessageId)
4506
5615
  continue;
@@ -4517,13 +5626,37 @@ function chatAgent(options) {
4517
5626
  continuation: payload.continuation,
4518
5627
  previousRunId: payload.previousRunId,
4519
5628
  };
5629
+ toInject.push({ injectedPayload, seqNum });
5630
+ }
5631
+ // Channel deliveries the predecessor never processed dispatch too, interleaved with the
5632
+ // browser messages by seq_num so a Slack message that arrived before a browser message
5633
+ // still runs before it. A delivery the boot payload itself carries is not queued twice.
5634
+ const wireDeliveryId = payload.channelEvent
5635
+ ?.deliveryId;
5636
+ for (const entry of replayedChannelInTail) {
5637
+ const entryDeliveryId = entry.channelEvent.deliveryId;
5638
+ if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
5639
+ continue;
5640
+ const injectedPayload = {
5641
+ chatId: payload.chatId,
5642
+ sessionId: payload.sessionId,
5643
+ metadata: entry.metadata ?? payload.metadata,
5644
+ trigger: "submit-message",
5645
+ channelEvent: entry.channelEvent,
5646
+ continuation: payload.continuation,
5647
+ previousRunId: payload.previousRunId,
5648
+ };
5649
+ toInject.push({ injectedPayload, seqNum: entry.seqNum });
5650
+ }
5651
+ toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
5652
+ for (const { injectedPayload, seqNum } of toInject) {
4520
5653
  bootInjectedQueue.push(injectedPayload);
4521
5654
  if (seqNum !== undefined) {
4522
5655
  recoveredSeqByPayload.set(injectedPayload, seqNum);
4523
5656
  dispatchedRecoveredSeqs.add(seqNum);
4524
5657
  }
4525
5658
  }
4526
- for (const entry of replayedInTail) {
5659
+ for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
4527
5660
  if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4528
5661
  chatInputRouter().settleRecovered(entry.seqNum);
4529
5662
  }
@@ -4566,15 +5699,47 @@ function chatAgent(options) {
4566
5699
  catch (error) {
4567
5700
  v3_1.logger.warn("chat.agent: tools() resolver threw at boot; restored history converted without toModelOutput", { error: error instanceof Error ? error.message : String(error) });
4568
5701
  }
4569
- }
4570
- try {
4571
- const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
4572
- locals_js_1.locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
4573
- locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
4574
- const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
4575
- accumulatedMessages = restored.messages;
4576
- laneCompacted = restored.compacted;
4577
- laneInjections = restored.injections;
5702
+ }
5703
+ try {
5704
+ const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
5705
+ locals_js_1.locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
5706
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5707
+ const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
5708
+ accumulatedMessages = restored.messages;
5709
+ laneCompacted = restored.compacted;
5710
+ laneInjections = restored.injections;
5711
+ sessionChannelBinding = bootRuntimeState?.channelBinding;
5712
+ const interruptedStream = sessionChannelBinding?.stream;
5713
+ if (interruptedStream && sessionChannelBinding) {
5714
+ const binding = sessionChannelBinding;
5715
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5716
+ clearOpenReplyStream();
5717
+ if (connector?.endInterruptedStream) {
5718
+ try {
5719
+ await connector.endInterruptedStream(interruptedStream, {
5720
+ handle: binding.handle,
5721
+ bound: true,
5722
+ });
5723
+ }
5724
+ catch (error) {
5725
+ v3_1.logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
5726
+ }
5727
+ }
5728
+ }
5729
+ if (sessionChannelBinding?.working) {
5730
+ const binding = sessionChannelBinding;
5731
+ clearChannelWorking();
5732
+ await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
5733
+ }
5734
+ if (sessionChannelBinding) {
5735
+ for (const message of accumulatedUIMessages) {
5736
+ if (message.role !== "assistant")
5737
+ continue;
5738
+ for (const call of pendingToolCallsInMessage(message)) {
5739
+ channelRenderedToolCallIds.add(call.toolCallId);
5740
+ }
5741
+ }
5742
+ }
4578
5743
  if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4579
5744
  locals_js_1.locals.set(chatBackgroundQueueKey, [
4580
5745
  ...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
@@ -4961,26 +6126,434 @@ function chatAgent(options) {
4961
6126
  // Declared here so the finally can detach it — a handler leaked past
4962
6127
  // its turn duplicates every mid-stream message into the shared buffer.
4963
6128
  let turnMsgSub;
6129
+ let turnControlSub;
4964
6130
  let capturedPartialResponse;
4965
6131
  let responseCommitted = false;
4966
6132
  const turnBufferedChunks = [];
6133
+ let channelConn;
6134
+ let channelWorkingReaction;
6135
+ let channelWireEvent;
6136
+ /** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
6137
+ let channelHandle;
6138
+ /** True when no channel delivery started this turn and egress goes through the session binding. */
6139
+ let boundTurn = false;
6140
+ let channelAckRef;
6141
+ let channelStreamEditor;
6142
+ let droppedStaleInteraction = false;
6143
+ /** True when the connector mapped this delivery to an empty message: no turn runs. */
6144
+ let droppedEmptyMessage = false;
6145
+ let droppedUnknownConnector = false;
6146
+ /** True when this delivery was a stop the connector drops while idle: no turn runs. */
6147
+ let droppedIdleControl = false;
6148
+ /** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
6149
+ let channelReplyStream;
6150
+ /** True once this turn reported `working`, so its end reports the outcome. */
6151
+ let channelLifecycleStarted = false;
6152
+ /** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
6153
+ const channelTurnStartedAt = Date.now();
6154
+ /** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
6155
+ let channelTurnRan = false;
6156
+ /** True when this turn resolves a pending tool call from a channel interaction callback. */
6157
+ let channelInteractionResolved = false;
6158
+ let channelFinalAnswerPosted = false;
6159
+ let channelFinalSendFailed = false;
6160
+ /** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
6161
+ let channelStreamErrorText;
6162
+ /** The original error behind that chunk, captured from `toUIMessageStream`'s `onError` when auto-piping. */
6163
+ let channelStreamError;
6164
+ /** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
6165
+ let channelTurnText;
4967
6166
  try {
4968
6167
  // Extract turn-level context before entering the span. Slim
4969
6168
  // wire: at most one delta message per record. `headStartMessages`
4970
6169
  // is consumed at boot only (via `payload.headStartMessages`)
4971
6170
  // and intentionally discarded here.
4972
- const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, ...restWire } = currentWirePayload;
6171
+ const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
6172
+ let actionChannel;
6173
+ if (clickedAction !== undefined) {
6174
+ const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
6175
+ const click = clicked;
6176
+ actionChannel = {
6177
+ connectorId: click.connectorId,
6178
+ provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
6179
+ event: click.event,
6180
+ deliveryId: click.deliveryId,
6181
+ };
6182
+ currentWirePayload = {
6183
+ ...withoutChannelEvent,
6184
+ trigger: "action",
6185
+ action: clickedAction,
6186
+ actionSource: "channel",
6187
+ };
6188
+ }
6189
+ const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
4973
6190
  void _hsm;
4974
- const incomingMessages = incomingMessage
4975
- ? [incomingMessage]
6191
+ channelWireEvent = wireChannelEvent;
6192
+ let effectiveIncomingMessage = incomingMessage;
6193
+ const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
6194
+ turnClientData = clientData;
6195
+ turnIsActionTurn = false;
6196
+ currentTurnSource = wireChannelEvent
6197
+ ? "channel"
6198
+ : currentWirePayload.actionSource === "webhook"
6199
+ ? "webhook"
6200
+ : "client";
6201
+ currentTurnConnectorId = undefined;
6202
+ if (wireChannelEvent) {
6203
+ channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
6204
+ if (channelConn) {
6205
+ currentTurnConnectorId = channelConn.id;
6206
+ let idleControl = null;
6207
+ if (channelConn.onControl) {
6208
+ try {
6209
+ idleControl = channelConn.onControl(wireChannelEvent.event);
6210
+ }
6211
+ catch (controlError) {
6212
+ v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
6213
+ error: controlError,
6214
+ });
6215
+ }
6216
+ }
6217
+ const stopForEndedTurn = idleControl?.at !== undefined &&
6218
+ lastChannelTurnEndedAt !== undefined &&
6219
+ idleControl.at < lastChannelTurnEndedAt;
6220
+ droppedIdleControl =
6221
+ idleControl?.kind === "stop" &&
6222
+ (idleControl.whenIdle === "drop" || stopForEndedTurn);
6223
+ // Bind the session to this connector so a later turn that no channel delivery
6224
+ // started (an action turn, a browser message) can still answer into the thread.
6225
+ if (channelConn.bind && !droppedIdleControl) {
6226
+ try {
6227
+ const handle = channelConn.bind(wireChannelEvent.event);
6228
+ if (handle !== null && handle !== undefined) {
6229
+ const controls = sessionChannelBinding?.connectorId === channelConn.id
6230
+ ? sessionChannelBinding.controls
6231
+ : undefined;
6232
+ sessionChannelBinding = {
6233
+ connectorId: channelConn.id,
6234
+ handle,
6235
+ boundAt: new Date().toISOString(),
6236
+ deliveryId: wireChannelEvent.deliveryId,
6237
+ ...(controls ? { controls } : {}),
6238
+ };
6239
+ }
6240
+ }
6241
+ catch (bindError) {
6242
+ v3_1.logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
6243
+ error: bindError,
6244
+ });
6245
+ }
6246
+ }
6247
+ channelHandle =
6248
+ sessionChannelBinding?.connectorId === channelConn.id
6249
+ ? sessionChannelBinding.handle
6250
+ : undefined;
6251
+ const rawInteraction = droppedIdleControl
6252
+ ? null
6253
+ : (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
6254
+ let interaction = rawInteraction
6255
+ ? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
6256
+ : null;
6257
+ const accumulatorHasInteractionToolCall = interaction != null &&
6258
+ accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
6259
+ if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
6260
+ try {
6261
+ const hydratedForInteraction = (await loadContextHook({
6262
+ chatId: currentWirePayload.chatId,
6263
+ turn,
6264
+ trigger: "submit-message",
6265
+ incomingMessages: [],
6266
+ previousMessages: [...accumulatedUIMessages],
6267
+ clientData,
6268
+ continuation,
6269
+ previousRunId,
6270
+ }));
6271
+ accumulatedUIMessages = hydratedForInteraction;
6272
+ accumulatedMessages = await toModelMessages(hydratedForInteraction);
6273
+ laneCompacted = false;
6274
+ laneInjections = [];
6275
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6276
+ if (!interaction) {
6277
+ interaction =
6278
+ resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
6279
+ }
6280
+ }
6281
+ catch (hydrateError) {
6282
+ v3_1.logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
6283
+ }
6284
+ }
6285
+ const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
6286
+ ? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
6287
+ source: "channel",
6288
+ connectorId: channelConn.id,
6289
+ provider: channelConn.source,
6290
+ deliveryId: wireChannelEvent.deliveryId,
6291
+ })
6292
+ : undefined;
6293
+ const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
6294
+ ? undefined
6295
+ : channelConn.inbound(wireChannelEvent.event);
6296
+ if (droppedIdleControl) {
6297
+ const staleStop = idleControl?.at !== undefined &&
6298
+ lastChannelTurnStartedAt !== undefined &&
6299
+ idleControl.at < lastChannelTurnStartedAt;
6300
+ if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
6301
+ try {
6302
+ const hydratedForStop = (await loadContextHook({
6303
+ chatId: currentWirePayload.chatId,
6304
+ turn,
6305
+ trigger: "submit-message",
6306
+ incomingMessages: [],
6307
+ previousMessages: [],
6308
+ clientData,
6309
+ continuation,
6310
+ previousRunId,
6311
+ }));
6312
+ accumulatedUIMessages = hydratedForStop;
6313
+ accumulatedMessages = await toModelMessages(hydratedForStop);
6314
+ laneCompacted = false;
6315
+ laneInjections = [];
6316
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6317
+ }
6318
+ catch (hydrateError) {
6319
+ v3_1.logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
6320
+ error: hydrateError,
6321
+ });
6322
+ }
6323
+ }
6324
+ let lastAssistantIndex = -1;
6325
+ for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
6326
+ if (accumulatedUIMessages[i].role === "assistant") {
6327
+ lastAssistantIndex = i;
6328
+ break;
6329
+ }
6330
+ }
6331
+ const lastAssistant = lastAssistantIndex === -1
6332
+ ? undefined
6333
+ : accumulatedUIMessages[lastAssistantIndex];
6334
+ const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
6335
+ ? pendingToolCallsInMessage(lastAssistant)
6336
+ : [];
6337
+ if (lastAssistant && stoppedCalls.length > 0) {
6338
+ const errorText = idleControl?.message || "stopped";
6339
+ const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
6340
+ if (stoppedMessage) {
6341
+ accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
6342
+ const pausedRun = await toModelMessages([
6343
+ stripProviderMetadata(lastAssistant),
6344
+ ]);
6345
+ if (pausedRun.length === 0) {
6346
+ accumulatedMessages = [
6347
+ ...accumulatedMessages,
6348
+ ...(await toModelMessages([stoppedMessage])),
6349
+ ];
6350
+ }
6351
+ else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
6352
+ v3_1.logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
6353
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
6354
+ laneCompacted = false;
6355
+ laneInjections = [];
6356
+ }
6357
+ for (const call of stoppedCalls)
6358
+ channelStoppedToolCallIds.add(call.toolCallId);
6359
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6360
+ }
6361
+ const refs = sessionChannelBinding?.connectorId === channelConn.id
6362
+ ? sessionChannelBinding.controls
6363
+ : undefined;
6364
+ const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
6365
+ for (const call of stoppedCalls) {
6366
+ channelRenderedToolCallIds.delete(call.toolCallId);
6367
+ }
6368
+ if (refs && sessionChannelBinding) {
6369
+ const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
6370
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6371
+ void _controls;
6372
+ sessionChannelBinding =
6373
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6374
+ }
6375
+ await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
6376
+ if (channelConn.cancelInteraction) {
6377
+ try {
6378
+ await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
6379
+ }
6380
+ catch (cancelError) {
6381
+ v3_1.logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
6382
+ error: cancelError,
6383
+ });
6384
+ }
6385
+ }
6386
+ await channelLifecycle.report(channelConn, "idle", {
6387
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6388
+ firstTurn: false,
6389
+ });
6390
+ }
6391
+ }
6392
+ else if (resolutionMessage) {
6393
+ effectiveIncomingMessage = resolutionMessage;
6394
+ channelInteractionResolved = true;
6395
+ const resolvedCallId = interaction?.toolCallId;
6396
+ const controlRefs = sessionChannelBinding?.controls;
6397
+ if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
6398
+ const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
6399
+ void _resolved;
6400
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6401
+ void _controls;
6402
+ sessionChannelBinding =
6403
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6404
+ }
6405
+ if (interaction && channelConn.finalizeInteraction) {
6406
+ try {
6407
+ await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
6408
+ }
6409
+ catch (finalizeError) {
6410
+ v3_1.logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
6411
+ error: finalizeError,
6412
+ });
6413
+ }
6414
+ }
6415
+ }
6416
+ else if (rawInteraction) {
6417
+ droppedStaleInteraction = true;
6418
+ v3_1.logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
6419
+ deliveryId: wireChannelEvent.deliveryId,
6420
+ });
6421
+ }
6422
+ else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
6423
+ droppedEmptyMessage = true;
6424
+ v3_1.logger.debug("chat.agent: dropping a channel delivery with an empty message", {
6425
+ deliveryId: wireChannelEvent.deliveryId,
6426
+ });
6427
+ }
6428
+ else {
6429
+ // A session that starts on a conversation with history: let the connector supply
6430
+ // it, so the agent answers with the context a reader of the thread has.
6431
+ if (channelConn.seed && accumulatedUIMessages.length === 0) {
6432
+ try {
6433
+ const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
6434
+ event: wireChannelEvent.event,
6435
+ deliveryId: wireChannelEvent.deliveryId,
6436
+ }), {
6437
+ source: "channel",
6438
+ connectorId: channelConn.id,
6439
+ provider: channelConn.source,
6440
+ deliveryId: wireChannelEvent.deliveryId,
6441
+ });
6442
+ if (seeded.length > 0) {
6443
+ accumulatedUIMessages = seeded;
6444
+ accumulatedMessages = await toModelMessages(seeded);
6445
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6446
+ }
6447
+ }
6448
+ catch (seedError) {
6449
+ v3_1.logger.warn("chat.agent: channel seed() failed; starting the session empty", {
6450
+ error: seedError,
6451
+ });
6452
+ }
6453
+ }
6454
+ effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
6455
+ source: "channel",
6456
+ connectorId: channelConn.id,
6457
+ provider: channelConn.source,
6458
+ deliveryId: wireChannelEvent.deliveryId,
6459
+ ...(wireChannelEvent.externalDeliveryId
6460
+ ? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
6461
+ : {}),
6462
+ });
6463
+ if (channelConn.send && channelConn.ack) {
6464
+ const recoveryPending = locals_js_1.locals.get(chatChannelRecoveryPendingKey);
6465
+ const recovered = recoveryPending?.value === true;
6466
+ if (recovered)
6467
+ recoveryPending.value = false;
6468
+ const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
6469
+ if (ackMessage) {
6470
+ try {
6471
+ const ackResult = await channelConn.send(ackMessage, {
6472
+ event: wireChannelEvent.event,
6473
+ handle: channelHandle,
6474
+ deliveryId: wireChannelEvent.deliveryId,
6475
+ mode: channelConn.delivery,
6476
+ final: false,
6477
+ bound: false,
6478
+ });
6479
+ channelAckRef = ackResult?.ref;
6480
+ }
6481
+ catch (ackError) {
6482
+ v3_1.logger.warn("chat.agent: channel ack post failed; continuing", {
6483
+ error: ackError,
6484
+ });
6485
+ }
6486
+ }
6487
+ }
6488
+ try {
6489
+ channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
6490
+ if (channelWorkingReaction) {
6491
+ await applyChannelReaction(channelConn, wireChannelEvent, {
6492
+ name: channelWorkingReaction,
6493
+ });
6494
+ }
6495
+ }
6496
+ catch (reactionError) {
6497
+ v3_1.logger.warn("chat.agent: channel working reaction failed", {
6498
+ error: reactionError,
6499
+ });
6500
+ }
6501
+ }
6502
+ }
6503
+ else {
6504
+ droppedUnknownConnector = true;
6505
+ v3_1.logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
6506
+ connectorId: wireChannelEvent.connectorId,
6507
+ deliveryId: wireChannelEvent.deliveryId,
6508
+ });
6509
+ }
6510
+ }
6511
+ if (!wireChannelEvent &&
6512
+ sessionChannelBinding &&
6513
+ currentWirePayload.trigger !== "close" &&
6514
+ currentWirePayload.trigger !== "preload") {
6515
+ // No channel delivery started this turn, but the session is bound to a channel: answer
6516
+ // into it. Reactions need the triggering message, so a bound turn adds none; its reply,
6517
+ // any error and any approval controls go to the bound thread.
6518
+ const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
6519
+ if (bound?.send) {
6520
+ channelConn = bound;
6521
+ channelHandle = sessionChannelBinding.handle;
6522
+ boundTurn = true;
6523
+ currentTurnConnectorId = bound.id;
6524
+ }
6525
+ }
6526
+ const incomingMessages = effectiveIncomingMessage
6527
+ ? [effectiveIncomingMessage]
4976
6528
  : [];
4977
6529
  // Cleaning happens once here so `extractLastUserMessageText` and
4978
6530
  // every downstream consumer see the same message shape — and
4979
6531
  // `cleanupAbortedParts` no longer has to be re-applied below.
4980
6532
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4981
- const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4982
- turnClientData = clientData;
4983
6533
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
6534
+ channelTurnRan =
6535
+ !!channelConn &&
6536
+ !!(wireChannelEvent || boundTurn) &&
6537
+ currentWirePayload.trigger !== "action" &&
6538
+ !droppedIdleControl &&
6539
+ !droppedStaleInteraction &&
6540
+ !droppedEmptyMessage;
6541
+ const startChannelLifecycle = async () => {
6542
+ lastChannelTurnStartedAt = channelTurnStartedAt;
6543
+ if (!channelConn?.lifecycle)
6544
+ return;
6545
+ channelLifecycleStarted = true;
6546
+ if (sessionChannelBinding?.connectorId === channelConn.id) {
6547
+ sessionChannelBinding = { ...sessionChannelBinding, working: true };
6548
+ }
6549
+ await channelLifecycle.report(channelConn, "working", {
6550
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6551
+ firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
6552
+ ...(lastUserMessage ? { prompt: lastUserMessage } : {}),
6553
+ });
6554
+ };
6555
+ if (channelTurnRan)
6556
+ await startChannelLifecycle();
4984
6557
  // Actions are not turns. They use a different span name
4985
6558
  // and don't carry a turn.number. Branched on at `isAction`.
4986
6559
  const isAction = currentWirePayload.trigger === "action";
@@ -4995,7 +6568,7 @@ function chatAgent(options) {
4995
6568
  : "tabler-message-chatbot",
4996
6569
  [v3_1.SemanticInternalAttributes.ENTITY_TYPE]: isAction ? "chat-action" : "chat-turn",
4997
6570
  };
4998
- if (lastUserMessage) {
6571
+ if (lastUserMessage && recordUserMessage) {
4999
6572
  turnAttributes["chat.user_message"] = lastUserMessage;
5000
6573
  // Show a truncated preview of the user message as an accessory
5001
6574
  const preview = lastUserMessage.length > 80
@@ -5006,7 +6579,7 @@ function chatAgent(options) {
5006
6579
  style: "codepath",
5007
6580
  }));
5008
6581
  }
5009
- if (wireMetadata !== undefined) {
6582
+ if (wireMetadata !== undefined && recordClientData) {
5010
6583
  turnAttributes["chat.client_data"] =
5011
6584
  typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata);
5012
6585
  }
@@ -5031,11 +6604,16 @@ function chatAgent(options) {
5031
6604
  turn,
5032
6605
  continuation,
5033
6606
  clientData,
6607
+ recordUserMessage,
5034
6608
  });
5035
6609
  // Resolve the per-turn `tools` set now that turn context
5036
6610
  // (incl. parsed clientData) exists, so every toModelMessages
5037
6611
  // call this turn can re-apply tool `toModelOutput`.
5038
6612
  await resolveTurnTools();
6613
+ // The channel surface for this turn, handed to run() and reachable from a tool's
6614
+ // execute through chat.surface(), so a connector can ship tools the model drives.
6615
+ const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
6616
+ locals_js_1.locals.set(chatChannelSurfaceKey, channelSurface);
5039
6617
  // Per-turn stop controller (reset each turn)
5040
6618
  const stopController = new AbortController();
5041
6619
  currentStopController = stopController;
@@ -5057,12 +6635,19 @@ function chatAgent(options) {
5057
6635
  ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
5058
6636
  const msg = record.data
5059
6637
  .payload;
6638
+ const wireChannel = msg
6639
+ .channelEvent;
6640
+ const steeredChannel = wireChannel != null
6641
+ ? channelSteeringMessage(channels, wireChannel, msg.messageId)
6642
+ : undefined;
6643
+ if (wireChannel != null && !steeredChannel)
6644
+ return;
5060
6645
  // Slim wire: at most one delta message per record. The
5061
6646
  // pendingMessages handler reads `msg.message` directly
5062
6647
  // instead of slicing an array — a wire record arrives
5063
6648
  // with the new user message in `.message`, or no message
5064
6649
  // at all (regenerate / preload / close / handover-prepare).
5065
- const lastUIMessage = msg.message;
6650
+ const lastUIMessage = (steeredChannel?.message ?? msg.message);
5066
6651
  if (lastUIMessage) {
5067
6652
  if (pmConfig.onReceived) {
5068
6653
  try {
@@ -5083,7 +6668,10 @@ function chatAgent(options) {
5083
6668
  queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
5084
6669
  return;
5085
6670
  }
5086
- const modelMsgs = await toModelMessages([lastUIMessage]);
6671
+ const converted = await toModelMessages([lastUIMessage]);
6672
+ const modelMsgs = steeredChannel && channelDownloader
6673
+ ? await resolveChannelFiles(converted, channelDownloader)
6674
+ : converted;
5087
6675
  queue.push({
5088
6676
  uiMessage: lastUIMessage,
5089
6677
  modelMessages: modelMsgs,
@@ -5098,6 +6686,52 @@ function chatAgent(options) {
5098
6686
  })
5099
6687
  : undefined;
5100
6688
  turnMsgSub = msgSub;
6689
+ /**
6690
+ * Channel deliveries while this turn runs. A delivery the connector maps to `stop`
6691
+ * aborts this turn through the same controller the stop input uses, and is taken
6692
+ * off the route so it does not also run as a turn. Any other message is
6693
+ * acknowledged through the connector's `received` as soon as it arrives, and left
6694
+ * queued for steering or the next turn.
6695
+ */
6696
+ const controlSub = channels?.some((c) => c.onControl || c.received)
6697
+ ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
6698
+ const msg = record.data
6699
+ .payload;
6700
+ const ce = msg.channelEvent;
6701
+ if (!ce)
6702
+ return;
6703
+ const conn = channels?.find((c) => c.id === ce.connectorId);
6704
+ if (!conn)
6705
+ return;
6706
+ let control = null;
6707
+ try {
6708
+ control = conn.onControl?.(ce.event) ?? null;
6709
+ }
6710
+ catch (controlError) {
6711
+ v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
6712
+ error: controlError,
6713
+ });
6714
+ }
6715
+ if (!control) {
6716
+ acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
6717
+ ? sessionChannelBinding.handle
6718
+ : undefined);
6719
+ return;
6720
+ }
6721
+ if (control.kind !== "stop")
6722
+ return;
6723
+ const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
6724
+ if (!earlierTurnStop && !stopController.signal.aborted) {
6725
+ stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
6726
+ }
6727
+ // Observers are offered a record before it is queued, so the take has to
6728
+ // wait a tick or it finds nothing and the stop runs again as a turn.
6729
+ void Promise.resolve().then(() => {
6730
+ chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
6731
+ });
6732
+ })
6733
+ : undefined;
6734
+ turnControlSub = controlSub;
5101
6735
  // Track new messages for this turn (user input + assistant response).
5102
6736
  const turnNewModelMessages = [];
5103
6737
  const turnNewUIMessages = [];
@@ -5127,7 +6761,8 @@ function chatAgent(options) {
5127
6761
  let actionChangedHistory = false;
5128
6762
  if (isAction) {
5129
6763
  // Parse and validate the action payload
5130
- const parsedAction = parseAction
6764
+ const isWebhookAction = currentWirePayload.actionSource === "webhook";
6765
+ const parsedAction = parseAction && !isWebhookAction
5131
6766
  ? await parseAction(currentWirePayload.action)
5132
6767
  : currentWirePayload.action;
5133
6768
  // Hydrate messages from backend if configured
@@ -5168,6 +6803,7 @@ function chatAgent(options) {
5168
6803
  clientData,
5169
6804
  uiMessages: accumulatedUIMessages,
5170
6805
  messages: accumulatedMessages,
6806
+ ...(actionChannel ? { channel: actionChannel } : {}),
5171
6807
  });
5172
6808
  }, {
5173
6809
  attributes: {
@@ -5202,7 +6838,11 @@ function chatAgent(options) {
5202
6838
  // snapshot + `session.out` replay (or `hydrateMessages`,
5203
6839
  // which also fires per-turn below). Per-turn handling is
5204
6840
  // therefore a delta merge, not a full-history reset.
5205
- if (currentWirePayload.trigger !== "action") {
6841
+ if (currentWirePayload.trigger !== "action" &&
6842
+ !droppedStaleInteraction &&
6843
+ !droppedEmptyMessage &&
6844
+ !droppedUnknownConnector &&
6845
+ !droppedIdleControl) {
5206
6846
  let cleanedUIMessages = cleanedIncomingMessages;
5207
6847
  // Turn-0 head-start with hydrateMessages: the boot seeding from
5208
6848
  // `payload.headStartMessages` is non-hydrate-only, so ship the
@@ -5454,17 +7094,41 @@ function chatAgent(options) {
5454
7094
  // calling the model would prefill its own last reply. Keyed on
5455
7095
  // the model tail, so a `tool`-terminated chain (a merged tool
5456
7096
  // approval) still runs.
5457
- const isNoOpTurn = !isAction &&
5458
- !splicedHandoverPartial &&
5459
- currentWirePayload.trigger === "submit-message" &&
5460
- turnNewUIMessages.length === 0 &&
5461
- accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
7097
+ // A channel delivery the run decided not to turn into a turn: a stale
7098
+ // interaction callback (no pending tool call matched) or an event for
7099
+ // a connector this agent does not list. Nothing to answer either way.
7100
+ const isDroppedChannelDelivery = droppedStaleInteraction ||
7101
+ droppedEmptyMessage ||
7102
+ droppedUnknownConnector ||
7103
+ droppedIdleControl;
7104
+ // A resolved interaction is never a no-op: the assistant may already have text after
7105
+ // the pending call, so the lane ends on an assistant message, but the model has not
7106
+ // seen the decision yet.
7107
+ const isNoOpTurn = isDroppedChannelDelivery ||
7108
+ (!isAction &&
7109
+ !splicedHandoverPartial &&
7110
+ !channelInteractionResolved &&
7111
+ currentWirePayload.trigger === "submit-message" &&
7112
+ turnNewUIMessages.length === 0 &&
7113
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
5462
7114
  if (isNoOpTurn) {
5463
7115
  msgSub?.off();
5464
- v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
5465
- chatId: currentWirePayload.chatId,
5466
- messageId: currentWirePayload.messageId,
5467
- });
7116
+ turnControlSub?.off();
7117
+ if (channelTurnRan)
7118
+ lastChannelTurnEndedAt = Date.now();
7119
+ clearChannelWorking();
7120
+ if (channelLifecycleStarted) {
7121
+ await channelLifecycle.report(channelConn, "idle", {
7122
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7123
+ firstTurn: false,
7124
+ });
7125
+ }
7126
+ if (!isDroppedChannelDelivery) {
7127
+ v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
7128
+ chatId: currentWirePayload.chatId,
7129
+ messageId: currentWirePayload.messageId,
7130
+ });
7131
+ }
5468
7132
  settleRecoveredTurn(currentWirePayload);
5469
7133
  await writeTurnCompleteChunk(currentWirePayload.chatId);
5470
7134
  // Not a turn — don't consume an iteration.
@@ -5480,6 +7144,24 @@ function chatAgent(options) {
5480
7144
  // sees the same `turn` value — actions don't count.
5481
7145
  if (isAction) {
5482
7146
  if (isActionTurn(actionResult)) {
7147
+ if (actionResult.message !== undefined) {
7148
+ const asked = (typeof actionResult.message === "string"
7149
+ ? {
7150
+ id: (0, ai_runtime_js_1.generateId)(),
7151
+ role: "user",
7152
+ parts: [{ type: "text", text: actionResult.message }],
7153
+ }
7154
+ : actionResult.message.id
7155
+ ? actionResult.message
7156
+ : { ...actionResult.message, id: (0, ai_runtime_js_1.generateId)() });
7157
+ accumulatedUIMessages = [...accumulatedUIMessages, asked];
7158
+ accumulatedMessages = [
7159
+ ...accumulatedMessages,
7160
+ ...(await toModelMessages([asked])),
7161
+ ];
7162
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
7163
+ actionChangedHistory = true;
7164
+ }
5483
7165
  // Persist the edit before the turn starts, so a turn that is
5484
7166
  // cancelled or runs out of memory continues from the edited
5485
7167
  // history rather than from the snapshot the edit replaced.
@@ -5488,6 +7170,11 @@ function chatAgent(options) {
5488
7170
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5489
7171
  }
5490
7172
  actionTurn = true;
7173
+ turnIsActionTurn = true;
7174
+ if (boundTurn && channelConn) {
7175
+ channelTurnRan = true;
7176
+ await startChannelLifecycle();
7177
+ }
5491
7178
  }
5492
7179
  else if (actionResult !== undefined) {
5493
7180
  throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
@@ -5496,6 +7183,7 @@ function chatAgent(options) {
5496
7183
  }
5497
7184
  else {
5498
7185
  msgSub?.off();
7186
+ turnControlSub?.off();
5499
7187
  if (actionChangedHistory) {
5500
7188
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5501
7189
  }
@@ -5697,12 +7385,87 @@ function chatAgent(options) {
5697
7385
  }
5698
7386
  else {
5699
7387
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
7388
+ const runMessages = channelDownloader
7389
+ ? await resolveChannelFiles(preparedMessages, channelDownloader)
7390
+ : preparedMessages;
7391
+ // Channel observers for this turn are built before run(). The progress
7392
+ // observer and the native reply stream are shared with a manual chat.pipe()
7393
+ // inside run() through the turn tap and stay open across several pipes; the
7394
+ // ack stream editor only rides the automatic pipe, because a manual pipe
7395
+ // captures no response and posts no final reply that would supersede a
7396
+ // partial edit. The finally of this turn stops all of them and clears the tap;
7397
+ // the turn boundary ends the native stream.
7398
+ if (channelConn) {
7399
+ const observers = [];
7400
+ const tapObservers = [];
7401
+ if (channelConn.delivery === "stream" &&
7402
+ channelConn.stream &&
7403
+ (wireChannelEvent || channelHandle)) {
7404
+ const streamConnectorId = channelConn.id;
7405
+ const opened = (ref) => {
7406
+ if (sessionChannelBinding?.connectorId !== streamConnectorId)
7407
+ return;
7408
+ sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
7409
+ if (hydrateMessages)
7410
+ return;
7411
+ void saveTranscript({
7412
+ reason: "turn-start",
7413
+ messages: accumulatedUIMessages,
7414
+ turn,
7415
+ trigger: storageTrigger(currentWirePayload.trigger),
7416
+ clientData,
7417
+ lastOutEventId: lastSnapshotOutEventId,
7418
+ skipIfUnchanged: true,
7419
+ carryInCursor: true,
7420
+ }).catch((error) => {
7421
+ v3_1.logger.warn("chat.agent: saving the open reply stream failed", {
7422
+ error: error instanceof Error ? error.message : String(error),
7423
+ });
7424
+ });
7425
+ };
7426
+ try {
7427
+ channelReplyStream =
7428
+ channelConn.stream({
7429
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7430
+ opened,
7431
+ }) ?? undefined;
7432
+ }
7433
+ catch (streamError) {
7434
+ v3_1.logger.warn("chat.agent: channel stream() threw; posting the reply", {
7435
+ error: streamError,
7436
+ });
7437
+ }
7438
+ }
7439
+ if (channelReplyStream) {
7440
+ const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
7441
+ observers.push(replyObserver);
7442
+ tapObservers.push(replyObserver);
7443
+ }
7444
+ else if (wireChannelEvent &&
7445
+ channelConn.send &&
7446
+ channelConn.delivery === "stream" &&
7447
+ channelAckRef) {
7448
+ observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
7449
+ }
7450
+ if (channelConn.progress && (wireChannelEvent || channelHandle)) {
7451
+ const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
7452
+ observers.push(progressObserver);
7453
+ tapObservers.push(progressObserver);
7454
+ }
7455
+ if (tapObservers.length > 0) {
7456
+ const tapObserver = composeChannelObservers(tapObservers);
7457
+ locals_js_1.locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
7458
+ }
7459
+ if (observers.length > 0) {
7460
+ channelStreamEditor = composeChannelObservers(observers);
7461
+ }
7462
+ }
5700
7463
  runResult = await userRun({
5701
7464
  ...restWire,
5702
7465
  // A turn requested by chat.turn() is not the action itself:
5703
7466
  // a run() that short-circuits on "action" must still answer.
5704
7467
  ...(actionTurn ? { trigger: "action-turn" } : {}),
5705
- messages: preparedMessages,
7468
+ messages: runMessages,
5706
7469
  clientData,
5707
7470
  continuation,
5708
7471
  previousRunId,
@@ -5715,6 +7478,7 @@ function chatAgent(options) {
5715
7478
  cancelSignal,
5716
7479
  stopSignal,
5717
7480
  streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
7481
+ channel: channelSurface,
5718
7482
  });
5719
7483
  }
5720
7484
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5741,6 +7505,12 @@ function chatAgent(options) {
5741
7505
  // messageId. Without this, the frontend and backend generate IDs
5742
7506
  // independently and they won't match for ID-based dedup.
5743
7507
  generateMessageId: resolvedOptions.generateMessageId ?? ai_runtime_js_1.generateId,
7508
+ onError: (error) => {
7509
+ channelStreamError ??= error;
7510
+ return resolvedOptions.onError
7511
+ ? resolvedOptions.onError(error)
7512
+ : uiStreamErrorText(error);
7513
+ },
5744
7514
  onFinish: ({ responseMessage, finishReason, }) => {
5745
7515
  capturedResponseMessage = responseMessage;
5746
7516
  capturedPartialResponse = responseMessage;
@@ -5748,7 +7518,13 @@ function chatAgent(options) {
5748
7518
  resolveOnFinish();
5749
7519
  },
5750
7520
  });
5751
- await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
7521
+ // The automatic pipe feeds every observer (progress and the ack stream editor)
7522
+ // itself; the manual tap is cleared so pipeChat does not feed progress twice.
7523
+ locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
7524
+ const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
7525
+ ? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
7526
+ : uiStream;
7527
+ await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
5752
7528
  signal: combinedSignal,
5753
7529
  spanName: "stream response",
5754
7530
  }, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
@@ -5771,6 +7547,9 @@ function chatAgent(options) {
5771
7547
  }
5772
7548
  finally {
5773
7549
  msgSub?.off();
7550
+ turnControlSub?.off();
7551
+ locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
7552
+ await channelStreamEditor?.stop();
5774
7553
  }
5775
7554
  // Wait for onFinish to fire — on abort this may resolve slightly
5776
7555
  // after pipeChat, since the stream's cancel() handler is async.
@@ -6014,6 +7793,16 @@ function chatAgent(options) {
6014
7793
  if (capturedResponseMessage) {
6015
7794
  responseCommitted = true;
6016
7795
  capturedPartialResponse = capturedResponseMessage;
7796
+ const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
7797
+ channelStreamErrorText = errorChunk?.errorText;
7798
+ channelTurnText = turnBufferedChunks
7799
+ .map((chunk) => {
7800
+ const c = chunk;
7801
+ return c.type === "text-delta" && typeof c.delta === "string"
7802
+ ? c.delta
7803
+ : "";
7804
+ })
7805
+ .join("");
6017
7806
  turnBufferedChunks.length = 0;
6018
7807
  }
6019
7808
  if (runSignal.aborted)
@@ -6233,8 +8022,206 @@ function chatAgent(options) {
6233
8022
  }
6234
8023
  }
6235
8024
  settleRecoveredTurn(currentWirePayload);
8025
+ openChannelEgressLatch(currentWirePayload.chatId);
6236
8026
  // Write turn-complete control chunk — closes the frontend stream.
6237
8027
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
8028
+ // Tool calls that have not had controls posted yet; a resume with the rest still
8029
+ // pending must not repost them or hide the answer text behind them.
8030
+ const pendingToolCalls = (wireChannelEvent || boundTurn) &&
8031
+ channelConn?.send &&
8032
+ turnCompleteEvent.responseMessage
8033
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
8034
+ : [];
8035
+ const renderControls = !!((wireChannelEvent || boundTurn) &&
8036
+ pendingToolCalls.length > 0 &&
8037
+ channelConn?.renderInteraction);
8038
+ let channelErrorLine;
8039
+ if (channelStreamErrorText && (wireChannelEvent || boundTurn)) {
8040
+ v3_1.logger.warn("chat.agent: channel turn ended on a model or stream error", {
8041
+ errorText: channelStreamErrorText,
8042
+ });
8043
+ channelErrorLine = await channelTurnErrorMessage(channelConn, {
8044
+ error: channelStreamError instanceof Error
8045
+ ? channelStreamError
8046
+ : new Error(channelStreamErrorText),
8047
+ partialText: channelTurnText?.trim() ?? "",
8048
+ event: channelWireEvent?.event,
8049
+ runId: ctx.run.id,
8050
+ chatId: currentWirePayload.chatId,
8051
+ });
8052
+ }
8053
+ let channelReplyDelivered = false;
8054
+ if (channelReplyStream) {
8055
+ const replyStream = channelReplyStream;
8056
+ channelReplyStream = undefined;
8057
+ try {
8058
+ const ended = await replyStream.end({
8059
+ stopped: turnCompleteEvent.stopped,
8060
+ pending: renderControls,
8061
+ ...(channelStreamErrorText
8062
+ ? { errorText: channelErrorLine ?? exports.CHANNEL_TURN_ERROR_MESSAGE }
8063
+ : {}),
8064
+ });
8065
+ channelReplyDelivered = ended.delivered;
8066
+ }
8067
+ catch (streamError) {
8068
+ v3_1.logger.warn("chat.agent: channel reply stream end failed", {
8069
+ error: streamError,
8070
+ });
8071
+ }
8072
+ finally {
8073
+ clearOpenReplyStream();
8074
+ }
8075
+ }
8076
+ // Channel egress: map the turn's reply via outbound() and send it, editing the ack
8077
+ // placeholder posted at turn start (previousRef). A failed send is retried; one that
8078
+ // never lands is logged and the turn gets the error reaction. A manual-pipe turn has
8079
+ // no responseMessage, so it opts out.
8080
+ const finalChannelConn = channelConn;
8081
+ if ((wireChannelEvent || boundTurn) &&
8082
+ finalChannelConn?.send &&
8083
+ turnCompleteEvent.responseMessage) {
8084
+ if (renderControls) {
8085
+ for (const call of pendingToolCalls) {
8086
+ channelRenderedToolCallIds.add(call.toolCallId);
8087
+ }
8088
+ }
8089
+ const replyText = channelTurnText && channelTurnText.trim().length > 0
8090
+ ? channelTurnText
8091
+ : channelReplyText(turnCompleteEvent.responseMessage);
8092
+ // A model or stream error surfaces as an `error` chunk and an empty reply, not as a
8093
+ // thrown turn. The mapper gets it on this one call, with the connector's error line
8094
+ // standing in for an empty reply, so the placeholder never sits there as if the agent
8095
+ // were still working. The error text itself stays on the run, not in the channel.
8096
+ const finalReply = {
8097
+ // Prefer what this turn streamed; a resumed message carries earlier turns' text too.
8098
+ // A failed turn always ends on the connector's error line, after any partial text.
8099
+ text: channelErrorLine
8100
+ ? replyText.trim().length > 0
8101
+ ? `${replyText.trimEnd()}\n\n${channelErrorLine}`
8102
+ : channelErrorLine
8103
+ : replyText,
8104
+ message: turnCompleteEvent.responseMessage,
8105
+ final: true,
8106
+ stopped: turnCompleteEvent.stopped,
8107
+ ...(renderControls ? { pending: true } : {}),
8108
+ ...(channelStreamErrorText
8109
+ ? { error: new Error(channelStreamErrorText) }
8110
+ : {}),
8111
+ };
8112
+ let outboundMessage = null;
8113
+ if (!channelReplyDelivered) {
8114
+ try {
8115
+ outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
8116
+ }
8117
+ catch (outboundError) {
8118
+ v3_1.logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
8119
+ error: outboundError,
8120
+ });
8121
+ outboundMessage = defaultChannelOutbound(finalReply) ?? {
8122
+ text: "An unexpected error occurred",
8123
+ };
8124
+ }
8125
+ }
8126
+ const sendFinal = async (message) => {
8127
+ for (let attempt = 1;; attempt++) {
8128
+ try {
8129
+ const sent = await finalChannelConn.send(message, {
8130
+ event: wireChannelEvent?.event,
8131
+ handle: channelHandle,
8132
+ deliveryId: wireChannelEvent?.deliveryId,
8133
+ previousRef: channelAckRef,
8134
+ mode: finalChannelConn.delivery,
8135
+ final: true,
8136
+ bound: boundTurn,
8137
+ });
8138
+ channelFinalAnswerPosted = true;
8139
+ return sent ?? {};
8140
+ }
8141
+ catch (egressError) {
8142
+ if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
8143
+ channelFinalSendFailed = true;
8144
+ v3_1.logger.error("chat.agent: channel egress send failed", {
8145
+ error: egressError,
8146
+ attempts: attempt,
8147
+ });
8148
+ return undefined;
8149
+ }
8150
+ v3_1.logger.warn("chat.agent: channel egress send failed; retrying", {
8151
+ error: egressError,
8152
+ attempt,
8153
+ });
8154
+ await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
8155
+ }
8156
+ }
8157
+ };
8158
+ // A turn that pauses on a human decision often explains itself first (a summary,
8159
+ // the steps so far) and then calls the tool. That text is the answer so far: post it
8160
+ // as the placeholder edit, and post the controls as their own message after it.
8161
+ if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
8162
+ channelAckRef = undefined;
8163
+ }
8164
+ const channelMessage = renderControls
8165
+ ? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
8166
+ ? {
8167
+ event: wireChannelEvent.event,
8168
+ deliveryId: wireChannelEvent.deliveryId,
8169
+ }
8170
+ : {})
8171
+ : outboundMessage;
8172
+ if (channelMessage) {
8173
+ const controlsRef = (await sendFinal(channelMessage))?.ref;
8174
+ if (renderControls &&
8175
+ controlsRef &&
8176
+ sessionChannelBinding?.connectorId === finalChannelConn.id) {
8177
+ sessionChannelBinding = {
8178
+ ...sessionChannelBinding,
8179
+ controls: {
8180
+ ...sessionChannelBinding.controls,
8181
+ ...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
8182
+ },
8183
+ };
8184
+ }
8185
+ }
8186
+ }
8187
+ if (channelTurnRan)
8188
+ lastChannelTurnEndedAt = Date.now();
8189
+ clearChannelWorking();
8190
+ if (channelLifecycleStarted) {
8191
+ const stillPending = turnCompleteEvent.responseMessage
8192
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
8193
+ : false;
8194
+ await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey)
8195
+ ? "closed"
8196
+ : stillPending
8197
+ ? "waiting"
8198
+ : "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
8199
+ }
8200
+ // Lifecycle reaction: turn done. Remove "working", mark "done".
8201
+ if (wireChannelEvent && channelConn?.react) {
8202
+ try {
8203
+ if (channelWorkingReaction) {
8204
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8205
+ name: channelWorkingReaction,
8206
+ remove: true,
8207
+ });
8208
+ }
8209
+ const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
8210
+ ? channelConn.reactions?.error
8211
+ : channelConn.reactions?.done, wireChannelEvent.event);
8212
+ if (outcomeReaction) {
8213
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8214
+ name: outcomeReaction,
8215
+ });
8216
+ }
8217
+ }
8218
+ catch (reactionError) {
8219
+ v3_1.logger.warn("chat.agent: channel done reaction failed", {
8220
+ error: reactionError,
8221
+ });
8222
+ }
8223
+ }
8224
+ settleChannelEgressLatch(currentWirePayload.chatId);
6238
8225
  // Fire onTurnComplete — stream is closed, use for persistence.
6239
8226
  if (onTurnComplete) {
6240
8227
  await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -6352,6 +8339,7 @@ function chatAgent(options) {
6352
8339
  // chat.endRun() — same exit, no upgrade semantics.
6353
8340
  if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6354
8341
  await performChatClose();
8342
+ await reportChannelClosed();
6355
8343
  return "exit";
6356
8344
  }
6357
8345
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
@@ -6423,6 +8411,7 @@ function chatAgent(options) {
6423
8411
  currentWirePayload = next.output;
6424
8412
  // Close signal — exit the loop gracefully
6425
8413
  if (currentWirePayload.trigger === "close") {
8414
+ await reportChannelClosed();
6426
8415
  return "exit";
6427
8416
  }
6428
8417
  return "continue";
@@ -6440,12 +8429,16 @@ function chatAgent(options) {
6440
8429
  // Detach the turn's message handler first — left attached it would
6441
8430
  // eat the very message the wait below is waiting for.
6442
8431
  turnMsgSub?.off();
8432
+ turnControlSub?.off();
6443
8433
  if (turnError instanceof Error &&
6444
8434
  turnError.name === "AbortError" &&
6445
8435
  runSignal.aborted) {
8436
+ settleChannelEgressLatch(currentWirePayload.chatId);
8437
+ channelLifecycle.dispose();
6446
8438
  // Full run cancellation — exit immediately
6447
8439
  throw turnError;
6448
8440
  }
8441
+ settleChannelEgressLatch(currentWirePayload.chatId);
6449
8442
  // OOM errors must escape the turn loop so the task runtime can
6450
8443
  // honor `retry.outOfMemory.machine` (set on chat.agent via
6451
8444
  // `oomMachine`). Catching them here would keep the dead worker
@@ -6456,6 +8449,107 @@ function chatAgent(options) {
6456
8449
  if (turnError instanceof v3_1.OutOfMemoryError) {
6457
8450
  throw turnError;
6458
8451
  }
8452
+ if (channelWireEvent && channelConn?.react) {
8453
+ try {
8454
+ if (channelWorkingReaction) {
8455
+ await applyChannelReaction(channelConn, channelWireEvent, {
8456
+ name: channelWorkingReaction,
8457
+ remove: true,
8458
+ });
8459
+ }
8460
+ const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
8461
+ if (errorReaction) {
8462
+ await applyChannelReaction(channelConn, channelWireEvent, {
8463
+ name: errorReaction,
8464
+ });
8465
+ }
8466
+ }
8467
+ catch (reactionError) {
8468
+ v3_1.logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
8469
+ }
8470
+ }
8471
+ // The channel gets the connector's error line, never the error itself: the run keeps that.
8472
+ const channelErrorText = channelReplyStream || ((channelWireEvent || boundTurn) && channelConn?.send)
8473
+ ? await channelTurnErrorMessage(channelConn, {
8474
+ error: turnError instanceof Error ? turnError : new Error(String(turnError)),
8475
+ partialText: channelTurnText?.trim() ?? "",
8476
+ event: channelWireEvent?.event,
8477
+ runId: ctx.run.id,
8478
+ chatId: currentWirePayload.chatId,
8479
+ })
8480
+ : exports.CHANNEL_TURN_ERROR_MESSAGE;
8481
+ let errorStreamDelivered = false;
8482
+ if (channelReplyStream) {
8483
+ const replyStream = channelReplyStream;
8484
+ channelReplyStream = undefined;
8485
+ try {
8486
+ const ended = await replyStream.end({
8487
+ stopped: false,
8488
+ pending: false,
8489
+ errorText: channelErrorText,
8490
+ });
8491
+ errorStreamDelivered = ended.delivered;
8492
+ }
8493
+ catch (streamError) {
8494
+ v3_1.logger.warn("chat.agent: channel reply stream end failed", {
8495
+ error: streamError,
8496
+ });
8497
+ }
8498
+ finally {
8499
+ clearOpenReplyStream();
8500
+ }
8501
+ }
8502
+ if ((channelWireEvent || boundTurn) && channelConn?.send) {
8503
+ const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
8504
+ const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
8505
+ let errorMessage = null;
8506
+ try {
8507
+ errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
8508
+ text: channelErrorText,
8509
+ message: {
8510
+ id: channelErrorMessageId,
8511
+ role: "assistant",
8512
+ parts: [{ type: "text", text: channelErrorText }],
8513
+ },
8514
+ final: true,
8515
+ stopped: false,
8516
+ error: turnError,
8517
+ });
8518
+ }
8519
+ catch (outboundError) {
8520
+ v3_1.logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
8521
+ error: outboundError,
8522
+ });
8523
+ errorMessage = { text: channelErrorText };
8524
+ }
8525
+ if (errorMessage && !errorStreamDelivered) {
8526
+ try {
8527
+ await channelConn.send(errorMessage, {
8528
+ event: channelWireEvent?.event,
8529
+ handle: channelHandle,
8530
+ deliveryId: channelWireEvent?.deliveryId,
8531
+ previousRef: errorPreviousRef,
8532
+ mode: channelConn.delivery,
8533
+ final: true,
8534
+ bound: boundTurn,
8535
+ });
8536
+ }
8537
+ catch (egressError) {
8538
+ v3_1.logger.warn("chat.agent: channel error egress send failed", {
8539
+ error: egressError,
8540
+ });
8541
+ }
8542
+ }
8543
+ }
8544
+ if (channelTurnRan)
8545
+ lastChannelTurnEndedAt = Date.now();
8546
+ clearChannelWorking();
8547
+ if (channelLifecycleStarted) {
8548
+ await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
8549
+ ...channelTurnCtx(channelWireEvent, channelHandle),
8550
+ firstTurn: false,
8551
+ });
8552
+ }
6459
8553
  let errorTurnCompleteResult;
6460
8554
  try {
6461
8555
  await withChatWriter(async (writer) => {
@@ -6712,6 +8806,7 @@ function chatAgent(options) {
6712
8806
  .get(chatManagedResponseKey)
6713
8807
  ?.close()
6714
8808
  .catch(() => { });
8809
+ turnControlSub?.off();
6715
8810
  }
6716
8811
  }
6717
8812
  }
@@ -6735,6 +8830,45 @@ function chatAgent(options) {
6735
8830
  schema: clientDataSchema,
6736
8831
  });
6737
8832
  }
8833
+ // Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
8834
+ // The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
8835
+ if (events) {
8836
+ for (const wh of events) {
8837
+ v3_1.resourceCatalog.markSessionWebhookClaimed(wh.id);
8838
+ v3_1.resourceCatalog.registerWebhookSubscriber({
8839
+ endpointId: wh.endpointId,
8840
+ target: {
8841
+ type: "session",
8842
+ id: `${options.id}:${wh.id}`,
8843
+ taskIdentifier: options.id,
8844
+ keyTemplate: wh.key,
8845
+ actionType: wh.type,
8846
+ deliverAs: "action",
8847
+ ...(wh.filter ? { filter: wh.filter } : {}),
8848
+ },
8849
+ });
8850
+ }
8851
+ }
8852
+ // Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
8853
+ // verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
8854
+ if (channels) {
8855
+ for (const ch of channels) {
8856
+ v3_1.resourceCatalog.markSessionWebhookClaimed(ch.id);
8857
+ v3_1.resourceCatalog.registerWebhookSubscriber({
8858
+ endpointId: ch.endpointId,
8859
+ target: {
8860
+ type: "session",
8861
+ id: `${options.id}:${ch.id}`,
8862
+ taskIdentifier: options.id,
8863
+ keyTemplate: ch.key,
8864
+ connectorId: ch.id,
8865
+ deliverAs: "message",
8866
+ ...(ch.filter ? { filter: ch.filter } : {}),
8867
+ ...(ch.startOn ? { startOn: ch.startOn } : {}),
8868
+ },
8869
+ });
8870
+ }
8871
+ }
6738
8872
  return task;
6739
8873
  }
6740
8874
  function composeHooks(builderHook, taskHook) {
@@ -7030,6 +9164,22 @@ function resolveUIMessageStreamOptions() {
7030
9164
  // ---------------------------------------------------------------------------
7031
9165
  // Stop detection
7032
9166
  // ---------------------------------------------------------------------------
9167
+ /**
9168
+ * The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
9169
+ * tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
9170
+ * `surface()`. Pass the connector's surface type to get its helpers typed:
9171
+ *
9172
+ * @example
9173
+ * ```ts
9174
+ * execute: async ({ steps }) => {
9175
+ * const linear = chat.surface<LinearRunSurface>();
9176
+ * if (linear) await linear.plan(steps);
9177
+ * }
9178
+ * ```
9179
+ */
9180
+ function getChannelSurface() {
9181
+ return locals_js_1.locals.get(chatChannelSurfaceKey);
9182
+ }
7033
9183
  /**
7034
9184
  * Check whether the user stopped generation during the current turn.
7035
9185
  *
@@ -9027,6 +11177,10 @@ function createChatLoadTranscriptAction(storage, options) {
9027
11177
  exports.chat = {
9028
11178
  /** Create a chat agent. See {@link chatAgent}. */
9029
11179
  agent: chatAgent,
11180
+ /** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
11181
+ event: webhooks_js_1.chatEvent,
11182
+ /** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
11183
+ channels: chatChannels,
9030
11184
  /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
9031
11185
  customAgent: chatCustomAgent,
9032
11186
  /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
@@ -9045,6 +11199,8 @@ exports.chat = {
9045
11199
  local: chatLocal,
9046
11200
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
9047
11201
  createAccessToken: createChatAccessToken,
11202
+ /** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
11203
+ createWatchToken: createChatWatchToken,
9048
11204
  /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
9049
11205
  setTurnTimeout,
9050
11206
  /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
@@ -9055,6 +11211,8 @@ exports.chat = {
9055
11211
  setUIMessageStreamOptions,
9056
11212
  /** Check if the current turn was stopped by the user. See {@link isStopped}. */
9057
11213
  isStopped,
11214
+ /** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
11215
+ surface: getChannelSurface,
9058
11216
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
9059
11217
  requestUpgrade,
9060
11218
  /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */