@trigger.dev/sdk 4.7.3 → 4.8.0-webhooks.20261015065339

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 (154) hide show
  1. package/dist/commonjs/v3/ai.d.ts +560 -8
  2. package/dist/commonjs/v3/ai.js +2263 -178
  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/concurrencyLimits.d.ts +37 -7
  7. package/dist/commonjs/v3/concurrencyLimits.js +38 -70
  8. package/dist/commonjs/v3/concurrencyLimits.js.map +1 -1
  9. package/dist/commonjs/v3/index.d.ts +3 -1
  10. package/dist/commonjs/v3/index.js +23 -17
  11. package/dist/commonjs/v3/index.js.map +1 -1
  12. package/dist/commonjs/v3/modelFileDownload.d.ts +10 -0
  13. package/dist/commonjs/v3/modelFileDownload.js +164 -0
  14. package/dist/commonjs/v3/modelFileDownload.js.map +1 -0
  15. package/dist/commonjs/v3/runs.d.ts +22 -1
  16. package/dist/commonjs/v3/runs.js +6 -3
  17. package/dist/commonjs/v3/runs.js.map +1 -1
  18. package/dist/commonjs/v3/test/index.d.ts +2 -1
  19. package/dist/commonjs/v3/test/index.js +4 -1
  20. package/dist/commonjs/v3/test/index.js.map +1 -1
  21. package/dist/commonjs/v3/test/mock-channel-connector.d.ts +172 -0
  22. package/dist/commonjs/v3/test/mock-channel-connector.js +173 -0
  23. package/dist/commonjs/v3/test/mock-channel-connector.js.map +1 -0
  24. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +81 -1
  25. package/dist/commonjs/v3/test/mock-chat-agent.js +137 -10
  26. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  27. package/dist/commonjs/v3/transcriptStorage.d.ts +44 -1
  28. package/dist/commonjs/v3/transcriptStorage.js +24 -0
  29. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  30. package/dist/commonjs/v3/webhookWaiters.d.ts +80 -0
  31. package/dist/commonjs/v3/webhookWaiters.js +156 -0
  32. package/dist/commonjs/v3/webhookWaiters.js.map +1 -0
  33. package/dist/commonjs/v3/webhooks.d.ts +178 -2
  34. package/dist/commonjs/v3/webhooks.js +322 -1
  35. package/dist/commonjs/v3/webhooks.js.map +1 -1
  36. package/dist/commonjs/version.js +1 -1
  37. package/dist/esm/v3/ai.d.ts +560 -8
  38. package/dist/esm/v3/ai.js +2257 -178
  39. package/dist/esm/v3/ai.js.map +1 -1
  40. package/dist/esm/v3/chat.d.ts +23 -0
  41. package/dist/esm/v3/chat.js.map +1 -1
  42. package/dist/esm/v3/concurrencyLimits.d.ts +37 -7
  43. package/dist/esm/v3/concurrencyLimits.js +38 -70
  44. package/dist/esm/v3/concurrencyLimits.js.map +1 -1
  45. package/dist/esm/v3/index.d.ts +3 -1
  46. package/dist/esm/v3/index.js +1 -0
  47. package/dist/esm/v3/index.js.map +1 -1
  48. package/dist/esm/v3/modelFileDownload.d.ts +10 -0
  49. package/dist/esm/v3/modelFileDownload.js +161 -0
  50. package/dist/esm/v3/modelFileDownload.js.map +1 -0
  51. package/dist/esm/v3/runs.d.ts +22 -1
  52. package/dist/esm/v3/runs.js +6 -3
  53. package/dist/esm/v3/runs.js.map +1 -1
  54. package/dist/esm/v3/test/index.d.ts +2 -1
  55. package/dist/esm/v3/test/index.js +2 -1
  56. package/dist/esm/v3/test/index.js.map +1 -1
  57. package/dist/esm/v3/test/mock-channel-connector.d.ts +172 -0
  58. package/dist/esm/v3/test/mock-channel-connector.js +170 -0
  59. package/dist/esm/v3/test/mock-channel-connector.js.map +1 -0
  60. package/dist/esm/v3/test/mock-chat-agent.d.ts +81 -1
  61. package/dist/esm/v3/test/mock-chat-agent.js +137 -11
  62. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  63. package/dist/esm/v3/transcriptStorage.d.ts +44 -1
  64. package/dist/esm/v3/transcriptStorage.js +24 -0
  65. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  66. package/dist/esm/v3/webhookWaiters.d.ts +80 -0
  67. package/dist/esm/v3/webhookWaiters.js +149 -0
  68. package/dist/esm/v3/webhookWaiters.js.map +1 -0
  69. package/dist/esm/v3/webhooks.d.ts +178 -2
  70. package/dist/esm/v3/webhooks.js +318 -1
  71. package/dist/esm/v3/webhooks.js.map +1 -1
  72. package/dist/esm/version.js +1 -1
  73. package/docs/ai-chat/actions.mdx +6 -1
  74. package/docs/ai-chat/backend.mdx +34 -0
  75. package/docs/ai-chat/patterns/sub-agents.mdx +33 -0
  76. package/docs/ai-chat/pending-messages.mdx +2 -0
  77. package/docs/ai-chat/reference.mdx +50 -2
  78. package/docs/apikeys.mdx +3 -3
  79. package/docs/billing-limits.mdx +1 -1
  80. package/docs/cli-env-commands.mdx +5 -4
  81. package/docs/cli-init-commands.mdx +27 -1
  82. package/docs/cli-login-commands.mdx +20 -0
  83. package/docs/cli-projects-commands.mdx +13 -1
  84. package/docs/cli-runs-commands.mdx +3 -2
  85. package/docs/cli-update-commands.mdx +9 -3
  86. package/docs/cli-whoami-commands.mdx +12 -0
  87. package/docs/concurrency.mdx +32 -0
  88. package/docs/config/extensions/audioWaveform.mdx +4 -1
  89. package/docs/config/extensions/overview.mdx +2 -0
  90. package/docs/config/extensions/prismaExtension.mdx +2 -3
  91. package/docs/config/extensions/puppeteer.mdx +3 -5
  92. package/docs/config/extensions/pythonExtension.mdx +9 -1
  93. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  94. package/docs/deploy-environment-variables.mdx +1 -1
  95. package/docs/deployment/atomic-deployment.mdx +2 -0
  96. package/docs/deployment/dev-branches.mdx +5 -5
  97. package/docs/deployment/overview.mdx +8 -7
  98. package/docs/deployment/preview-branches.mdx +6 -3
  99. package/docs/errors-retrying.mdx +4 -4
  100. package/docs/how-it-works.mdx +5 -5
  101. package/docs/introduction.mdx +6 -0
  102. package/docs/limits.mdx +26 -8
  103. package/docs/logging.mdx +1 -1
  104. package/docs/machines.mdx +1 -1
  105. package/docs/mcp-introduction.mdx +8 -7
  106. package/docs/mcp-tools.mdx +91 -1
  107. package/docs/migrating-from-v3.mdx +1 -1
  108. package/docs/observability/dashboards.mdx +2 -0
  109. package/docs/observability/query.mdx +6 -2
  110. package/docs/private-networking/aws-console-setup.mdx +1 -1
  111. package/docs/private-networking/overview.mdx +1 -1
  112. package/docs/quick-start.mdx +3 -40
  113. package/docs/realtime/auth.mdx +1 -1
  114. package/docs/realtime/backend/input-streams.mdx +159 -0
  115. package/docs/realtime/backend/streams.mdx +25 -10
  116. package/docs/realtime/how-it-works.mdx +1 -1
  117. package/docs/realtime/react-hooks/overview.mdx +1 -1
  118. package/docs/realtime/react-hooks/streams.mdx +2 -2
  119. package/docs/realtime/react-hooks/subscribe.mdx +19 -3
  120. package/docs/realtime/react-hooks/triggering.mdx +2 -2
  121. package/docs/realtime/react-hooks/use-wait-token.mdx +1 -1
  122. package/docs/realtime/run-object.mdx +47 -24
  123. package/docs/replaying.mdx +26 -1
  124. package/docs/reports.mdx +4 -2
  125. package/docs/run-tests.mdx +2 -2
  126. package/docs/run-usage.mdx +3 -3
  127. package/docs/runs/heartbeats.mdx +2 -2
  128. package/docs/runs/list-best-practices.mdx +234 -0
  129. package/docs/runs/metadata.mdx +7 -10
  130. package/docs/runs.mdx +18 -8
  131. package/docs/self-hosting/env/supervisor.mdx +2 -0
  132. package/docs/self-hosting/env/webapp.mdx +5 -0
  133. package/docs/self-hosting/overview.mdx +26 -0
  134. package/docs/tags.mdx +6 -0
  135. package/docs/tasks/scheduled.mdx +11 -5
  136. package/docs/triggering.mdx +8 -0
  137. package/docs/troubleshooting-alerts.mdx +102 -3
  138. package/docs/troubleshooting.mdx +1 -1
  139. package/docs/upgrading-packages.mdx +3 -3
  140. package/docs/vercel-integration.mdx +13 -3
  141. package/docs/versioning.mdx +1 -1
  142. package/docs/wait-for-token.mdx +3 -3
  143. package/docs/webhooks/channels.mdx +128 -0
  144. package/docs/webhooks/connect.mdx +80 -0
  145. package/docs/webhooks/deliveries.mdx +92 -0
  146. package/docs/webhooks/filters.mdx +109 -0
  147. package/docs/webhooks/human-in-the-loop.mdx +149 -0
  148. package/docs/webhooks/linear.mdx +185 -0
  149. package/docs/webhooks/overview.mdx +99 -0
  150. package/docs/webhooks/session-routing.mdx +132 -0
  151. package/docs/webhooks/slack.mdx +360 -0
  152. package/docs/webhooks/sources.mdx +217 -0
  153. package/docs/webhooks/waiters.mdx +170 -0
  154. package/package.json +3 -2
package/dist/esm/v3/ai.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, tryCatch, } from "@trigger.dev/core/v3";
2
+ import { chatEvent, normalizeKeyString } from "./webhooks.js";
3
+ import { fetchPublicFile } from "./modelFileDownload.js";
2
4
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
3
5
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
4
6
  import { trace } from "@opentelemetry/api";
@@ -73,6 +75,14 @@ const chatSessionHandleKey = locals.create("chat.sessionHandle");
73
75
  // is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
74
76
  // metadata reads this directly rather than the Session handle id.
75
77
  const chatExternalIdKey = locals.create("chat.externalId");
78
+ /**
79
+ * Set at boot when a continuation run inherited an interrupted turn (a
80
+ * partial assistant reply on `session.out`). Consumed by the first channel
81
+ * turn's ack so the re-emitted placeholder reads as a recovery resume rather
82
+ * than a silent duplicate. Boxed so the ack site can flip it to false.
83
+ * @internal
84
+ */
85
+ const chatChannelRecoveryPendingKey = locals.create("chat.channelRecoveryPending");
76
86
  /**
77
87
  * S2 seq_num of the most recent `turn-complete` control record written by
78
88
  * this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -356,14 +366,23 @@ export function __setReplaySessionInTailImplForTests(impl) {
356
366
  * @internal
357
367
  */
358
368
  async function replaySessionInTail(sessionId, options) {
369
+ return (await replaySessionInTailWithChannels(sessionId, options)).messages;
370
+ }
371
+ /**
372
+ * The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
373
+ * seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
374
+ */
375
+ async function replaySessionInTailWithChannels(sessionId, options) {
359
376
  if (replaySessionInTailImpl) {
360
- return await replaySessionInTailImpl(sessionId, options);
377
+ const replayed = await replaySessionInTailImpl(sessionId, options);
378
+ return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
361
379
  }
362
380
  const apiClient = apiClientManager.clientOrThrow();
363
381
  const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
364
382
  afterEventId: options?.lastEventId,
365
383
  });
366
384
  const out = [];
385
+ const channelEvents = [];
367
386
  for (const record of response.records) {
368
387
  // session.in writers POST `JSON.stringify(chunk)` directly; the
369
388
  // webapp wraps that in `{ data: <string>, id }` and stores it on
@@ -390,15 +409,23 @@ async function replaySessionInTail(sessionId, options) {
390
409
  if (!payload || payload.trigger !== "submit-message")
391
410
  continue;
392
411
  const message = payload.message;
393
- if (!message || typeof message !== "object")
412
+ if (!message || typeof message !== "object") {
413
+ if (payload.channelEvent && typeof payload.channelEvent === "object") {
414
+ channelEvents.push({
415
+ channelEvent: payload.channelEvent,
416
+ metadata: payload.metadata,
417
+ seqNum: record.seqNum,
418
+ });
419
+ }
394
420
  continue;
421
+ }
395
422
  out.push({
396
423
  message: message,
397
424
  metadata: payload.metadata,
398
425
  seqNum: record.seqNum,
399
426
  });
400
427
  }
401
- return out;
428
+ return { messages: out, channelEvents };
402
429
  }
403
430
  /**
404
431
  * Test-only entry point that bypasses
@@ -659,6 +686,37 @@ export const ai = {
659
686
  function createChatAccessToken(taskId) {
660
687
  return auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
661
688
  }
689
+ /**
690
+ * Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
691
+ *
692
+ * A session is addressed by `externalId`, so the same session is reachable from any surface that
693
+ * knows it: a web `useChat` client and a Slack thread converge on one session when they share an
694
+ * `externalId` (for a channel, the connector's `key` template produces it). This token carries
695
+ * `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
696
+ * stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
697
+ *
698
+ * Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
699
+ * (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
700
+ * a read+write token instead ({@link createChatStartSessionAction}).
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * // actions.ts
705
+ * "use server";
706
+ * import { chat } from "@trigger.dev/sdk/ai";
707
+ *
708
+ * export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
709
+ * ```
710
+ */
711
+ function createChatWatchToken(externalId, options) {
712
+ if (!externalId) {
713
+ throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
714
+ }
715
+ return auth.createPublicToken({
716
+ scopes: { read: { sessions: externalId } },
717
+ expirationTime: options?.tokenTTL ?? "1h",
718
+ });
719
+ }
662
720
  function createChatOutGate() {
663
721
  let resolveOpened;
664
722
  const opened = new Promise((resolve) => {
@@ -1895,7 +1953,16 @@ const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsCons
1895
1953
  * @internal
1896
1954
  */
1897
1955
  const chatPipeCountKey = locals.create("chat.pipeCount");
1956
+ /**
1957
+ * The current turn's channel observer tap, set before run() when the turn has channel observers
1958
+ * (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
1959
+ * manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
1960
+ * @internal
1961
+ */
1962
+ const chatTurnChannelTapKey = locals.create("chat.turnChannelTap");
1898
1963
  const chatStopControllerKey = locals.create("chat.stopController");
1964
+ const chatChannelSurfaceKey = locals.create("chat.channelSurface");
1965
+ const chatChannelDownloadKey = locals.create("chat.channelDownload");
1899
1966
  /** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
1900
1967
  const chatUIStreamStaticKey = locals.create("chat.uiMessageStreamOptions.static");
1901
1968
  /** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
@@ -3271,7 +3338,7 @@ function buildManagedStreamTextOptions(options, config) {
3271
3338
  * caller's value winning because `rest` is spread after `managed`. Pulling a
3272
3339
  * key out to "handle" it is how a caller's option gets silently dropped.
3273
3340
  */
3274
- const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3341
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
3275
3342
  const managed = toStreamTextOptions({
3276
3343
  registry,
3277
3344
  system: callerSystem ?? agentSystem,
@@ -3314,6 +3381,12 @@ function buildManagedStreamTextOptions(options, config) {
3314
3381
  if (typeof managed.prepareStep === "function") {
3315
3382
  managed.prepareStep = retainStepMessages(managed.prepareStep);
3316
3383
  }
3384
+ if (typeof callerDownload === "function") {
3385
+ const channelDownload = locals.get(chatChannelDownloadKey);
3386
+ managed.experimental_download = channelDownload
3387
+ ? channelAwareDownload(channelDownload, callerDownload)
3388
+ : callerDownload;
3389
+ }
3317
3390
  return { ...managed, ...rest };
3318
3391
  }
3319
3392
  /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
@@ -3543,6 +3616,10 @@ function toStreamTextOptions(options, retain = true) {
3543
3616
  if (retain && typeof result.prepareStep === "function") {
3544
3617
  result.prepareStep = retainStepMessages(result.prepareStep);
3545
3618
  }
3619
+ const channelDownload = locals.get(chatChannelDownloadKey);
3620
+ if (channelDownload) {
3621
+ result.experimental_download = channelAwareDownload(channelDownload);
3622
+ }
3546
3623
  return result;
3547
3624
  }
3548
3625
  const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
@@ -3557,6 +3634,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3557
3634
  * tools, steering, compaction, injected instructions and persistence, with no
3558
3635
  * action-specific handling.
3559
3636
  *
3637
+ * Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
3638
+ * "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
3639
+ *
3560
3640
  * @example
3561
3641
  * ```ts
3562
3642
  * onAction: async ({ action }) => {
@@ -3564,12 +3644,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3564
3644
  * chat.history.slice(0, -1);
3565
3645
  * return chat.turn();
3566
3646
  * }
3647
+ * if (action.type === "ask") return chat.turn(action.question);
3567
3648
  * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3568
3649
  * },
3569
3650
  * ```
3570
3651
  */
3571
- function chatTurn() {
3572
- return { [actionTurnBrand]: true };
3652
+ function chatTurn(message) {
3653
+ return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
3573
3654
  }
3574
3655
  function isActionTurn(value) {
3575
3656
  return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
@@ -3635,6 +3716,21 @@ function warnMissingOnActionOnce() {
3635
3716
  function isAsyncIterable(value) {
3636
3717
  return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
3637
3718
  }
3719
+ function readableStreamFromAsyncIterable(source) {
3720
+ const iterator = source[Symbol.asyncIterator]();
3721
+ return new ReadableStream({
3722
+ async pull(controller) {
3723
+ const next = await iterator.next();
3724
+ if (next.done)
3725
+ controller.close();
3726
+ else
3727
+ controller.enqueue(next.value);
3728
+ },
3729
+ async cancel(reason) {
3730
+ await iterator.return?.(reason);
3731
+ },
3732
+ });
3733
+ }
3638
3734
  function isReadableStream(value) {
3639
3735
  return (typeof value === "object" && value !== null && typeof value.getReader === "function");
3640
3736
  }
@@ -3690,6 +3786,10 @@ async function pipeChat(source, options, capture) {
3690
3786
  throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
3691
3787
  "an AsyncIterable, or a ReadableStream");
3692
3788
  }
3789
+ const channelTap = locals.get(chatTurnChannelTapKey);
3790
+ if (channelTap) {
3791
+ stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
3792
+ }
3693
3793
  const pipeOptions = {};
3694
3794
  if (options?.signal) {
3695
3795
  pipeOptions.signal = options.signal;
@@ -3719,160 +3819,1027 @@ async function pipeChat(source, options, capture) {
3719
3819
  await waitUntilComplete();
3720
3820
  }
3721
3821
  }
3722
- function chatCustomAgent(options) {
3723
- const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
3724
- const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3725
- const parseClientDataSync = clientDataSchema
3726
- ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
3727
- : undefined;
3728
- const task = createTask({
3729
- ...restOptions,
3730
- triggerSource: "agent",
3731
- agentConfig: { type: "ai-sdk-chat" },
3732
- run: async (payload, runOptions) => {
3733
- // Bind the run to its backing Session so module-level helpers
3734
- // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
3735
- // resolve to this chat's `.in` / `.out` channels. Address
3736
- // everywhere by `payload.chatId` (the session externalId) so the
3737
- // agent's writes and the transport's reads converge on the same
3738
- // S2 stream key + waitpoint key.
3739
- //
3740
- // The Session row is created server-side by `POST /sessions` (or
3741
- // `chat.createStartSessionAction`) before this run is triggered.
3742
- // No client-side upsert needed.
3743
- locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
3744
- locals.set(chatExternalIdKey, payload.chatId);
3745
- locals.set(chatAgentRunContextKey, runOptions.ctx);
3746
- locals.set(chatCustomAgentRunKey, true);
3747
- if (parseClientData && parseClientDataSync) {
3748
- locals.set(chatCustomAgentClientDataParserKey, {
3749
- parse: parseClientData,
3750
- parseSync: parseClientDataSync,
3751
- });
3822
+ /**
3823
+ * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
3824
+ * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
3825
+ * control. Use for providers without a preset, or to test the channel round-trip end to end.
3826
+ */
3827
+ export function chatChannelCustom(options) {
3828
+ 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, } = options;
3829
+ if (!endpoint?.id) {
3830
+ throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
3831
+ }
3832
+ resourceCatalog.registerDeclaredSessionWebhook(id);
3833
+ return {
3834
+ id,
3835
+ endpointId: endpoint.id,
3836
+ source: endpoint.source,
3837
+ key: normalizeKeyString(key),
3838
+ filter,
3839
+ startOn,
3840
+ inbound,
3841
+ bind,
3842
+ seed,
3843
+ outbound,
3844
+ ack,
3845
+ send,
3846
+ renderInteraction,
3847
+ onInteraction,
3848
+ finalizeInteraction,
3849
+ react,
3850
+ reactions,
3851
+ progress,
3852
+ surface,
3853
+ onControl,
3854
+ stream,
3855
+ endInterruptedStream,
3856
+ download,
3857
+ received,
3858
+ toAction,
3859
+ lifecycle,
3860
+ cancelInteraction,
3861
+ delivery: delivery ?? "final",
3862
+ };
3863
+ }
3864
+ const chatChannels = {
3865
+ /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
3866
+ custom: chatChannelCustom,
3867
+ };
3868
+ // Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
3869
+ function channelSeedMessages(seed, provenance) {
3870
+ return seed
3871
+ .filter((m) => m && typeof m.text === "string" && m.text.length > 0)
3872
+ .map((m, index) => {
3873
+ const meta = {
3874
+ ...provenance,
3875
+ seeded: true,
3876
+ ...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
3877
+ ...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
3878
+ };
3879
+ return {
3880
+ id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
3881
+ role: m.role === "assistant" ? "assistant" : "user",
3882
+ parts: [{ type: "text", text: m.text }],
3883
+ metadata: meta,
3884
+ };
3885
+ });
3886
+ }
3887
+ // Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
3888
+ // it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
3889
+ /**
3890
+ * True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
3891
+ * data parts. A button click from another app on a shared endpoint maps to one.
3892
+ */
3893
+ function isEmptyChannelInbound(input) {
3894
+ const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
3895
+ ? input.message
3896
+ : input;
3897
+ if (typeof message === "string")
3898
+ return message.trim() === "";
3899
+ return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
3900
+ }
3901
+ /** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
3902
+ const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
3903
+ /**
3904
+ * One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
3905
+ * the session first, the next one after a failure) and caches what they return by URL, so a file in
3906
+ * the history is fetched once per run rather than per step.
3907
+ */
3908
+ function makeChannelDownloader(connectors, handleFor) {
3909
+ const downloaders = connectors.filter((c) => typeof c.download === "function");
3910
+ if (downloaders.length === 0)
3911
+ return undefined;
3912
+ const cache = new Map();
3913
+ const sizes = new Map();
3914
+ let cachedBytes = 0;
3915
+ const evict = () => {
3916
+ for (const key of cache.keys()) {
3917
+ if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
3918
+ return;
3919
+ cachedBytes -= sizes.get(key) ?? 0;
3920
+ sizes.delete(key);
3921
+ cache.delete(key);
3922
+ }
3923
+ };
3924
+ const fetchOwned = async (url) => {
3925
+ const candidates = downloaders.map((connector) => ({
3926
+ connector,
3927
+ handle: handleFor(connector.id),
3928
+ }));
3929
+ const ordered = [
3930
+ ...candidates.filter((c) => c.handle !== undefined),
3931
+ ...candidates.filter((c) => c.handle === undefined),
3932
+ ];
3933
+ let failure;
3934
+ for (const { connector, handle } of ordered) {
3935
+ try {
3936
+ const file = await connector.download(url, { handle });
3937
+ if (file)
3938
+ return file;
3752
3939
  }
3753
- if (onClientDataValidationError) {
3754
- locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
3940
+ catch (error) {
3941
+ failure ??= { error };
3755
3942
  }
3756
- locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
3757
- // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
3758
- // trims `session.out` back to the previous turn boundary. Without
3759
- // this the slot is undefined and the trim never runs, so `.out`
3760
- // grows without bound for the whole custom-agent surface.
3761
- locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
3762
- markChatAgentRunForStreamsWarning();
3763
- taskContext.setConversationId(payload.chatId);
3764
- stampConversationIdOnActiveSpan(payload.chatId);
3765
- await installChatInputRouter(payload.chatId, {
3766
- resuming: Boolean(payload.continuation),
3767
- });
3768
- // A custom agent's loop is the customer's, so there is no exit site the
3769
- // SDK controls. Perform a requested close when `run()` returns, whatever
3770
- // shape the loop had. Idempotent, so the createSession iterator having
3771
- // already closed on its own exit costs nothing.
3772
- const withClose = async (result) => {
3773
- try {
3774
- return await result;
3775
- }
3776
- finally {
3777
- await performChatClose();
3778
- }
3779
- };
3780
- // Keep the schema-free path identical to the original custom-agent
3781
- // wrapper, including when userRun starts executing.
3782
- if (!parseClientData) {
3783
- return withClose(userRun(payload, runOptions));
3943
+ }
3944
+ if (failure)
3945
+ throw failure.error;
3946
+ return null;
3947
+ };
3948
+ return (url) => {
3949
+ const key = url.href;
3950
+ const cached = cache.get(key);
3951
+ if (cached)
3952
+ return cached;
3953
+ const pending = fetchOwned(url).then((file) => {
3954
+ if (file && cache.get(key) === pending) {
3955
+ sizes.set(key, file.data.byteLength);
3956
+ cachedBytes += file.data.byteLength;
3957
+ evict();
3784
3958
  }
3785
- const isHandoverBoot = payload.trigger === "handover-prepare";
3786
- const isMessagelessBoot = payload.trigger === "preload" ||
3787
- (payload.continuation === true &&
3788
- payload.message === undefined &&
3789
- payload.trigger !== "action" &&
3790
- payload.trigger !== "regenerate-message" &&
3791
- !isHandoverBoot);
3792
- const validated = await validateChatCustomAgentPayload(payload, {
3793
- // Preload and continuation boots do not represent a submitted turn,
3794
- // so there is no sender waiting for a terminal frame. Handover errors
3795
- // must be written after the warm response flushes and signals below.
3796
- writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3959
+ return file;
3960
+ }, (error) => {
3961
+ if (cache.get(key) === pending)
3962
+ cache.delete(key);
3963
+ throw error;
3964
+ });
3965
+ cache.set(key, pending);
3966
+ return pending;
3967
+ };
3968
+ }
3969
+ function fileUrlOf(part) {
3970
+ const p = part;
3971
+ const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
3972
+ if (raw instanceof URL)
3973
+ return raw;
3974
+ if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
3975
+ return undefined;
3976
+ try {
3977
+ return new URL(raw);
3978
+ }
3979
+ catch {
3980
+ return undefined;
3981
+ }
3982
+ }
3983
+ /**
3984
+ * Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
3985
+ * them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
3986
+ * copy of the messages, and the turn still runs. The transcript keeps the URL.
3987
+ */
3988
+ async function resolveChannelFiles(messages, download) {
3989
+ const failed = new Set();
3990
+ const mediaTypes = new Map();
3991
+ const urls = new Map();
3992
+ for (const message of messages) {
3993
+ if (!Array.isArray(message.content))
3994
+ continue;
3995
+ for (const part of message.content) {
3996
+ const url = fileUrlOf(part);
3997
+ if (url)
3998
+ urls.set(url.href, url);
3999
+ }
4000
+ }
4001
+ await Promise.all([...urls.values()].map(async (url) => {
4002
+ try {
4003
+ const file = await download(url);
4004
+ if (file?.mediaType)
4005
+ mediaTypes.set(url.href, file.mediaType);
4006
+ }
4007
+ catch (error) {
4008
+ failed.add(url.href);
4009
+ logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
4010
+ url: url.origin + url.pathname,
4011
+ error,
3797
4012
  });
3798
- if (validated.ok) {
3799
- return withClose(userRun(validated.payload, runOptions));
3800
- }
3801
- if (isHandoverBoot) {
3802
- const signal = await waitForHandover({
3803
- payload,
3804
- timeout: "1h",
3805
- spanName: "waiting for handover signal (invalid clientData)",
3806
- });
3807
- if (!signal || signal.kind === "handover-skip") {
3808
- return;
3809
- }
3810
- // The head-start writer flushes before sending this signal. Writing
3811
- // the terminal error now preserves stream order and closes the stitch.
3812
- await writeChatCustomAgentClientDataErrorToStream(payload);
3813
- return;
4013
+ }
4014
+ }));
4015
+ const isWildcard = (part) => {
4016
+ const mediaType = part.mediaType;
4017
+ return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
4018
+ };
4019
+ return messages.map((message) => {
4020
+ if (!Array.isArray(message.content))
4021
+ return message;
4022
+ let changed = false;
4023
+ const content = message.content.map((part) => {
4024
+ const url = fileUrlOf(part);
4025
+ if (!url)
4026
+ return part;
4027
+ if (failed.has(url.href)) {
4028
+ changed = true;
4029
+ const name = part.filename;
4030
+ return {
4031
+ type: "text",
4032
+ text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
4033
+ };
3814
4034
  }
3815
- // The Session base payload is sticky across continuation runs. If it is
3816
- // invalid, returning here would boot the same bad metadata again on the
3817
- // next message. Stay attached and wait for a valid wire frame instead.
3818
- const next = await messagesInput.waitWithIdleTimeout({
3819
- idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
3820
- timeout: "1h",
3821
- spanName: "waiting for valid clientData",
3822
- });
3823
- if (!next.ok || next.output.trigger === "close") {
3824
- return;
4035
+ const downloaded = mediaTypes.get(url.href);
4036
+ if (downloaded && isWildcard(part)) {
4037
+ changed = true;
4038
+ return { ...part, mediaType: downloaded };
3825
4039
  }
3826
- // Normal input frames omit run-level boot context. Carry it forward so
3827
- // a continuation still tells the custom loop to restore prior state.
3828
- const recoveredPayload = {
3829
- ...next.output,
3830
- continuation: next.output.continuation ?? payload.continuation,
3831
- previousRunId: next.output.previousRunId ?? payload.previousRunId,
3832
- sessionId: next.output.sessionId ?? payload.sessionId,
3833
- idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3834
- };
3835
- return withClose(userRun(recoveredPayload, runOptions));
3836
- },
4040
+ return part;
4041
+ });
4042
+ return changed ? { ...message, content } : message;
3837
4043
  });
3838
- // Register clientDataSchema so the CLI converts it to JSONSchema
3839
- // and stores it as payloadSchema — used by the Playground UI
3840
- if (clientDataSchema) {
3841
- resourceCatalog.updateTaskMetadata(options.id, {
3842
- schema: clientDataSchema,
4044
+ }
4045
+ /**
4046
+ * The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
4047
+ * every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
4048
+ * reads the URL itself, fetched from a public host otherwise).
4049
+ */
4050
+ function channelAwareDownload(download, fallback) {
4051
+ return async (requests) => {
4052
+ const results = await Promise.all(requests.map(async (request) => {
4053
+ const file = await download(request.url);
4054
+ return file ? { data: file.data, mediaType: file.mediaType } : null;
4055
+ }));
4056
+ const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
4057
+ if (misses.length === 0)
4058
+ return results;
4059
+ const rest = fallback
4060
+ ? await fallback(misses.map((i) => requests[i]))
4061
+ : await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : fetchPublicFile(requests[i].url)));
4062
+ misses.forEach((i, n) => {
4063
+ results[i] = rest[n] ?? null;
3843
4064
  });
4065
+ return results;
4066
+ };
4067
+ }
4068
+ /** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
4069
+ function channelClickAction(connectors, wire) {
4070
+ const channelEvent = wire;
4071
+ if (!channelEvent?.connectorId)
4072
+ return undefined;
4073
+ const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
4074
+ if (!connector?.toAction)
4075
+ return undefined;
4076
+ try {
4077
+ return connector.toAction(channelEvent.event) ?? undefined;
4078
+ }
4079
+ catch (error) {
4080
+ logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
4081
+ error,
4082
+ });
4083
+ return undefined;
3844
4084
  }
3845
- return task;
3846
4085
  }
3847
- function chatAgent(options) {
3848
- 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;
3849
- if (hydrateMessages) {
3850
- if (storage) {
3851
- throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3852
- "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3853
- "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3854
- }
3855
- if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
3856
- throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3857
- "`loadContext`. Both would own the model's context; keep one.");
3858
- }
3859
- warnHydrateMessagesDeprecatedOnce(options.id);
4086
+ /**
4087
+ * A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
4088
+ * the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
4089
+ * stop or other control, an interaction such as an approval click, an empty message, a connector
4090
+ * this agent does not list), which stays queued as its own turn.
4091
+ */
4092
+ function channelSteeringMessage(connectors, wire, messageId) {
4093
+ const connector = connectors?.find((c) => c.id === wire.connectorId);
4094
+ if (!connector)
4095
+ return undefined;
4096
+ try {
4097
+ if (connector.onControl?.(wire.event))
4098
+ return undefined;
4099
+ if (connector.onInteraction?.(wire.event))
4100
+ return undefined;
4101
+ if (connector.toAction?.(wire.event) != null)
4102
+ return undefined;
4103
+ const inbound = connector.inbound(wire.event);
4104
+ if (isEmptyChannelInbound(inbound))
4105
+ return undefined;
4106
+ const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
4107
+ source: "channel",
4108
+ connectorId: connector.id,
4109
+ provider: connector.source,
4110
+ deliveryId: wire.deliveryId,
4111
+ ...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
4112
+ });
4113
+ return { message };
3860
4114
  }
3861
- const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
3862
- const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
3863
- // chat.agent does not expose generic retry options (see docstring on
3864
- // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
3865
- // `oomMachine` is set we allow one retry on a larger machine; otherwise
3866
- // we keep the historical no-retry default.
3867
- const retry = oomMachine
3868
- ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
3869
- : { maxAttempts: 1 };
3870
- const task = createTask({
3871
- ...restOptions,
3872
- retry,
3873
- triggerSource: "agent",
3874
- agentConfig: { type: "ai-sdk-chat" },
3875
- run: async (payload, { signal: runSignal, ctx }) => {
4115
+ catch (error) {
4116
+ logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
4117
+ error,
4118
+ });
4119
+ return undefined;
4120
+ }
4121
+ }
4122
+ /**
4123
+ * Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
4124
+ * to answer (an interaction click, an action click, an empty message). Fire and forget.
4125
+ */
4126
+ function acknowledgeChannelMessage(connector, wire, handle) {
4127
+ if (!connector.received)
4128
+ return;
4129
+ try {
4130
+ if (connector.onInteraction?.(wire.event))
4131
+ return;
4132
+ if (connector.toAction?.(wire.event) != null)
4133
+ return;
4134
+ if (isEmptyChannelInbound(connector.inbound(wire.event)))
4135
+ return;
4136
+ void Promise.resolve(connector.received({
4137
+ event: wire.event,
4138
+ deliveryId: wire.deliveryId,
4139
+ handle,
4140
+ bound: false,
4141
+ })).catch(() => { });
4142
+ }
4143
+ catch {
4144
+ return;
4145
+ }
4146
+ }
4147
+ function channelUserMessage(input, messageId, provenance) {
4148
+ const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
4149
+ const message = (wrapped ? input.message : input);
4150
+ const extra = wrapped
4151
+ ? input
4152
+ : {};
4153
+ const meta = {
4154
+ ...provenance,
4155
+ ...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
4156
+ ...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
4157
+ };
4158
+ const base = typeof message === "string"
4159
+ ? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
4160
+ : message.id
4161
+ ? message
4162
+ : { ...message, id: messageId };
4163
+ const existing = base.metadata;
4164
+ return {
4165
+ ...base,
4166
+ metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
4167
+ };
4168
+ }
4169
+ /**
4170
+ * The assistant's closing text for a channel reply: the text after the last tool part in the message,
4171
+ * so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
4172
+ * is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
4173
+ * tool parts (an ordinary turn), so a plain reply is unchanged.
4174
+ */
4175
+ function channelReplyText(message) {
4176
+ const parts = (message.parts ?? []);
4177
+ const isTool = (p) => {
4178
+ const t = p?.type;
4179
+ return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
4180
+ };
4181
+ let lastToolIdx = -1;
4182
+ parts.forEach((p, i) => {
4183
+ if (isTool(p))
4184
+ lastToolIdx = i;
4185
+ });
4186
+ const textFrom = (from) => parts
4187
+ .slice(from)
4188
+ .map((p) => p && typeof p === "object" && p.type === "text"
4189
+ ? String(p.text)
4190
+ : "")
4191
+ .join("");
4192
+ const trailing = textFrom(lastToolIdx + 1);
4193
+ return trailing.trim().length > 0 ? trailing : textFrom(0);
4194
+ }
4195
+ /**
4196
+ * Tries at a turn's final channel reply before it is given up on and the turn shows the error
4197
+ * reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
4198
+ * a post is tried once.
4199
+ */
4200
+ const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
4201
+ /** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
4202
+ function pendingToolCallsInMessage(message) {
4203
+ const out = [];
4204
+ for (const part of (message.parts ?? [])) {
4205
+ if (!part || typeof part !== "object")
4206
+ continue;
4207
+ const type = part.type;
4208
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4209
+ if (!isTool || part.state !== "input-available")
4210
+ continue;
4211
+ const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
4212
+ if (typeof part.toolCallId === "string")
4213
+ out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
4214
+ }
4215
+ return out;
4216
+ }
4217
+ /**
4218
+ * Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
4219
+ * that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
4220
+ * undefined when no pending call of that tool exists.
4221
+ */
4222
+ function resolveInteractionToolCallId(resolution, messages) {
4223
+ if (resolution.toolCallId) {
4224
+ return resolution;
4225
+ }
4226
+ for (let i = messages.length - 1; i >= 0; i--) {
4227
+ const message = messages[i];
4228
+ if (message.role !== "assistant")
4229
+ continue;
4230
+ for (const call of pendingToolCallsInMessage(message).reverse()) {
4231
+ if (call.toolName === resolution.toolName) {
4232
+ return { ...resolution, toolCallId: call.toolCallId };
4233
+ }
4234
+ }
4235
+ }
4236
+ return undefined;
4237
+ }
4238
+ /**
4239
+ * Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
4240
+ * the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
4241
+ * the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
4242
+ */
4243
+ function buildInteractionResolutionMessage(resolution, messages, provenance) {
4244
+ // Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
4245
+ // one assistant message, and a second click on a decided call must not resolve a stale copy.
4246
+ for (const message of messages) {
4247
+ if (message.role !== "assistant")
4248
+ continue;
4249
+ for (const part of (message.parts ?? [])) {
4250
+ if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
4251
+ if (isResolvedToolState(part.state))
4252
+ return undefined;
4253
+ }
4254
+ }
4255
+ }
4256
+ for (let i = messages.length - 1; i >= 0; i--) {
4257
+ const message = messages[i];
4258
+ if (message.role !== "assistant")
4259
+ continue;
4260
+ for (const part of (message.parts ?? [])) {
4261
+ if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
4262
+ continue;
4263
+ const type = part.type;
4264
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4265
+ if (!isTool)
4266
+ continue;
4267
+ if (part.state !== "input-available")
4268
+ return undefined;
4269
+ const slimPart = {
4270
+ type,
4271
+ toolCallId: resolution.toolCallId,
4272
+ state: "output-available",
4273
+ output: resolution.output,
4274
+ };
4275
+ if (type === "dynamic-tool" && typeof part.toolName === "string")
4276
+ slimPart.toolName = part.toolName;
4277
+ return {
4278
+ id: message.id,
4279
+ role: "assistant",
4280
+ parts: [slimPart],
4281
+ ...(provenance ? { metadata: provenance } : {}),
4282
+ };
4283
+ }
4284
+ }
4285
+ return undefined;
4286
+ }
4287
+ // Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
4288
+ function defaultChannelOutbound(reply) {
4289
+ return reply.text ? { text: reply.text } : null;
4290
+ }
4291
+ // Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
4292
+ // pick one at random from an array. Returns undefined to skip (no choice / empty).
4293
+ export async function resolveReactionChoice(choice, event) {
4294
+ if (choice == null)
4295
+ return undefined;
4296
+ let value = typeof choice === "function" ? await choice(event) : choice;
4297
+ if (Array.isArray(value)) {
4298
+ if (value.length === 0)
4299
+ return undefined;
4300
+ value = value[Math.floor(Math.random() * value.length)];
4301
+ }
4302
+ return typeof value === "string" && value.length > 0 ? value : undefined;
4303
+ }
4304
+ // Best-effort reaction on the triggering message; a failure never breaks the turn.
4305
+ async function applyChannelReaction(connector, wireEvent, reaction) {
4306
+ if (!connector?.react || !wireEvent || !reaction.name)
4307
+ return;
4308
+ try {
4309
+ await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
4310
+ }
4311
+ catch (error) {
4312
+ logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
4313
+ }
4314
+ }
4315
+ // The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
4316
+ // `react`, so `payload.channel` is only present when reacting is actually possible.
4317
+ function buildChannelRunSurface(connector, wireEvent, handle) {
4318
+ if (!connector)
4319
+ return undefined;
4320
+ const canReact = !!connector.react && !!wireEvent;
4321
+ if (!canReact && !connector.surface)
4322
+ return undefined;
4323
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4324
+ let extra = {};
4325
+ if (connector.surface) {
4326
+ try {
4327
+ extra = connector.surface(turnCtx) ?? {};
4328
+ }
4329
+ catch (error) {
4330
+ logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
4331
+ }
4332
+ }
4333
+ const noop = async () => { };
4334
+ return {
4335
+ ...extra,
4336
+ react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
4337
+ unreact: canReact
4338
+ ? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
4339
+ : noop,
4340
+ };
4341
+ }
4342
+ function channelTurnCtx(wireEvent, handle) {
4343
+ return {
4344
+ ...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
4345
+ ...(handle !== undefined ? { handle } : {}),
4346
+ bound: !wireEvent,
4347
+ };
4348
+ }
4349
+ /**
4350
+ * Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
4351
+ * then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
4352
+ * `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
4353
+ * lose the results behind it, and nothing new lands after the turn ended.
4354
+ */
4355
+ function makeChannelProgressObserver(connector, wireEvent, handle) {
4356
+ const progress = connector.progress;
4357
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4358
+ const toolNames = new Map();
4359
+ let chain = Promise.resolve();
4360
+ let stopped = false;
4361
+ const emit = (event) => {
4362
+ chain = chain.then(async () => {
4363
+ try {
4364
+ await progress(event, turnCtx);
4365
+ }
4366
+ catch (error) {
4367
+ logger.warn("chat.agent: channel progress() failed; continuing", {
4368
+ error,
4369
+ type: event.type,
4370
+ });
4371
+ }
4372
+ });
4373
+ };
4374
+ return {
4375
+ observe(chunk) {
4376
+ if (stopped)
4377
+ return;
4378
+ const c = chunk;
4379
+ if (!c?.type || !c.toolCallId)
4380
+ return;
4381
+ if (c.type === "tool-input-start" && c.toolName) {
4382
+ toolNames.set(c.toolCallId, c.toolName);
4383
+ return;
4384
+ }
4385
+ if (c.type === "tool-input-available") {
4386
+ const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
4387
+ toolNames.set(c.toolCallId, toolName);
4388
+ emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, input: c.input });
4389
+ return;
4390
+ }
4391
+ if (c.type === "tool-output-available" && !c.preliminary) {
4392
+ emit({
4393
+ type: "tool-result",
4394
+ toolCallId: c.toolCallId,
4395
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4396
+ output: c.output,
4397
+ });
4398
+ return;
4399
+ }
4400
+ if (c.type === "tool-output-error") {
4401
+ emit({
4402
+ type: "tool-error",
4403
+ toolCallId: c.toolCallId,
4404
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4405
+ errorText: c.errorText ?? "tool failed",
4406
+ });
4407
+ }
4408
+ },
4409
+ async stop() {
4410
+ stopped = true;
4411
+ await chain;
4412
+ },
4413
+ };
4414
+ }
4415
+ /** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
4416
+ const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
4417
+ /**
4418
+ * Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
4419
+ * while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
4420
+ * `working` is still the latest report, so a refresh can never land after the turn went idle.
4421
+ */
4422
+ function makeChannelLifecycleReporter() {
4423
+ let chain = Promise.resolve();
4424
+ let seq = 0;
4425
+ let refreshTimer;
4426
+ let closed = false;
4427
+ const clearRefresh = () => {
4428
+ if (refreshTimer)
4429
+ clearTimeout(refreshTimer);
4430
+ refreshTimer = undefined;
4431
+ };
4432
+ const enqueue = (connector, state, ctx, reportSeq) => {
4433
+ chain = chain.then(async () => {
4434
+ if (ctx.refresh && reportSeq !== seq)
4435
+ return;
4436
+ try {
4437
+ await connector.lifecycle(state, ctx);
4438
+ }
4439
+ catch (error) {
4440
+ logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
4441
+ }
4442
+ });
4443
+ return chain;
4444
+ };
4445
+ return {
4446
+ report(connector, state, ctx) {
4447
+ if (!connector?.lifecycle || closed)
4448
+ return Promise.resolve();
4449
+ if (state === "closed")
4450
+ closed = true;
4451
+ const reportSeq = ++seq;
4452
+ clearRefresh();
4453
+ const done = enqueue(connector, state, ctx, reportSeq);
4454
+ if (state === "working") {
4455
+ const arm = () => {
4456
+ refreshTimer = setTimeout(() => {
4457
+ if (reportSeq !== seq)
4458
+ return;
4459
+ void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
4460
+ arm();
4461
+ }, CHANNEL_WORKING_REFRESH_MS);
4462
+ refreshTimer.unref?.();
4463
+ };
4464
+ arm();
4465
+ }
4466
+ return done;
4467
+ },
4468
+ dispose: clearRefresh,
4469
+ };
4470
+ }
4471
+ /**
4472
+ * The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
4473
+ * a stop that arrives while the session waits on a human decision. Undefined when none matched.
4474
+ */
4475
+ function stopPendingToolCalls(message, toolCallIds, errorText) {
4476
+ let changed = false;
4477
+ const parts = (message.parts ?? []).map((part) => {
4478
+ if (part &&
4479
+ typeof part === "object" &&
4480
+ toolCallIds.has(part.toolCallId) &&
4481
+ part.state === "input-available") {
4482
+ changed = true;
4483
+ return { ...part, state: "output-error", errorText };
4484
+ }
4485
+ return part;
4486
+ });
4487
+ return changed ? { ...message, parts } : undefined;
4488
+ }
4489
+ /**
4490
+ * Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
4491
+ * tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
4492
+ * ends the stream itself with the turn's outcome.
4493
+ */
4494
+ function makeChannelReplyStreamObserver(stream) {
4495
+ const toolNames = new Map();
4496
+ let stopped = false;
4497
+ const safe = (fn) => {
4498
+ try {
4499
+ fn();
4500
+ }
4501
+ catch (error) {
4502
+ logger.warn("chat.agent: channel reply stream threw; continuing", { error });
4503
+ }
4504
+ };
4505
+ return {
4506
+ observe(chunk) {
4507
+ if (stopped)
4508
+ return;
4509
+ const c = chunk;
4510
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4511
+ const delta = c.delta;
4512
+ safe(() => stream.text(delta));
4513
+ return;
4514
+ }
4515
+ if (!stream.progress || !c?.type || !c.toolCallId)
4516
+ return;
4517
+ const toolCallId = c.toolCallId;
4518
+ if (c.type === "tool-input-start" && c.toolName) {
4519
+ toolNames.set(toolCallId, c.toolName);
4520
+ }
4521
+ else if (c.type === "tool-input-available") {
4522
+ const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
4523
+ toolNames.set(toolCallId, toolName);
4524
+ safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, input: c.input }));
4525
+ }
4526
+ else if (c.type === "tool-output-available" && !c.preliminary) {
4527
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4528
+ safe(() => stream.progress({ type: "tool-result", toolCallId, toolName, output: c.output }));
4529
+ }
4530
+ else if (c.type === "tool-output-error") {
4531
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4532
+ const errorText = c.errorText ?? "tool failed";
4533
+ safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, errorText }));
4534
+ }
4535
+ },
4536
+ stop() {
4537
+ stopped = true;
4538
+ },
4539
+ };
4540
+ }
4541
+ function composeChannelObservers(observers) {
4542
+ return {
4543
+ observe(chunk) {
4544
+ for (const o of observers)
4545
+ o.observe(chunk);
4546
+ },
4547
+ async stop() {
4548
+ await Promise.all(observers.map((o) => o.stop()));
4549
+ },
4550
+ };
4551
+ }
4552
+ // "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
4553
+ // text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
4554
+ // the authoritative last write. Best-effort: an edit failure is logged, never fatal.
4555
+ const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
4556
+ function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
4557
+ const outbound = connector.outbound ?? defaultChannelOutbound;
4558
+ let latest = "";
4559
+ let lastSent = "";
4560
+ let timer;
4561
+ let inFlightPromise;
4562
+ let stopped = false;
4563
+ const arm = () => {
4564
+ if (stopped || timer)
4565
+ return;
4566
+ timer = setTimeout(() => {
4567
+ timer = undefined;
4568
+ void edit();
4569
+ }, CHANNEL_STREAM_EDIT_INTERVAL_MS);
4570
+ };
4571
+ const edit = async () => {
4572
+ if (stopped || inFlightPromise)
4573
+ return;
4574
+ const text = latest;
4575
+ if (text === lastSent)
4576
+ return;
4577
+ const message = outbound({
4578
+ text,
4579
+ message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
4580
+ final: false,
4581
+ stopped: false,
4582
+ });
4583
+ if (!message)
4584
+ return;
4585
+ inFlightPromise = (async () => {
4586
+ try {
4587
+ await connector.send(message, {
4588
+ event: channelEvent.event,
4589
+ handle,
4590
+ deliveryId: channelEvent.deliveryId,
4591
+ previousRef: ackRef,
4592
+ mode: "stream",
4593
+ final: false,
4594
+ bound: false,
4595
+ });
4596
+ lastSent = text;
4597
+ }
4598
+ catch (error) {
4599
+ logger.warn("chat.agent: channel stream edit failed", { error });
4600
+ }
4601
+ finally {
4602
+ inFlightPromise = undefined;
4603
+ if (!stopped && latest !== lastSent)
4604
+ arm();
4605
+ }
4606
+ })();
4607
+ await inFlightPromise;
4608
+ };
4609
+ return {
4610
+ observe(chunk) {
4611
+ if (stopped)
4612
+ return;
4613
+ const c = chunk;
4614
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4615
+ latest += c.delta;
4616
+ arm();
4617
+ }
4618
+ },
4619
+ async stop() {
4620
+ stopped = true;
4621
+ if (timer) {
4622
+ clearTimeout(timer);
4623
+ timer = undefined;
4624
+ }
4625
+ if (inFlightPromise) {
4626
+ try {
4627
+ await inFlightPromise;
4628
+ }
4629
+ catch { }
4630
+ }
4631
+ },
4632
+ };
4633
+ }
4634
+ /**
4635
+ * Wrap the reply stream so it debounce-edits the channel message as it streams.
4636
+ * Both `flush` (the stream completed) and `cancel` (the stream was aborted or
4637
+ * cancelled downstream) stop the editor, so a pending debounce timer can never
4638
+ * fire an edit after the turn ended, onto a message the next turn already owns.
4639
+ */
4640
+ /**
4641
+ * Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
4642
+ * is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
4643
+ * the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
4644
+ * stops it.
4645
+ */
4646
+ function makeChannelStreamTap(editor, options = {}) {
4647
+ const stopOnEnd = options.stopOnEnd ?? true;
4648
+ const transformer = {
4649
+ transform(chunk, controller) {
4650
+ editor.observe(chunk);
4651
+ controller.enqueue(chunk);
4652
+ },
4653
+ async flush() {
4654
+ if (stopOnEnd)
4655
+ await editor.stop();
4656
+ },
4657
+ async cancel() {
4658
+ if (stopOnEnd)
4659
+ await editor.stop();
4660
+ },
4661
+ };
4662
+ return new TransformStream(transformer);
4663
+ }
4664
+ export { makeChannelStreamEditor as __makeChannelStreamEditorForTests, makeChannelStreamTap as __makeChannelStreamTapForTests, makeChannelProgressObserver as __makeChannelProgressObserverForTests, makeChannelLifecycleReporter as __makeChannelLifecycleReporterForTests, };
4665
+ /**
4666
+ * Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
4667
+ * final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
4668
+ * settles it once that egress has finished, so a caller that saw the chunk can wait for the
4669
+ * connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
4670
+ * no open latch is quiet.
4671
+ */
4672
+ const channelEgressLatches = new Map();
4673
+ function openChannelEgressLatch(chatId) {
4674
+ let settle;
4675
+ const promise = new Promise((resolve) => (settle = resolve));
4676
+ channelEgressLatches.set(chatId, { promise, settle });
4677
+ }
4678
+ function settleChannelEgressLatch(chatId) {
4679
+ const latch = channelEgressLatches.get(chatId);
4680
+ if (!latch)
4681
+ return;
4682
+ channelEgressLatches.delete(chatId);
4683
+ latch.settle();
4684
+ }
4685
+ /** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
4686
+ export async function __waitForChannelEgressForTests(chatId) {
4687
+ await channelEgressLatches.get(chatId)?.promise;
4688
+ }
4689
+ function chatCustomAgent(options) {
4690
+ const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
4691
+ const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
4692
+ const parseClientDataSync = clientDataSchema
4693
+ ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
4694
+ : undefined;
4695
+ const task = createTask({
4696
+ ...restOptions,
4697
+ triggerSource: "agent",
4698
+ agentConfig: { type: "ai-sdk-chat" },
4699
+ run: async (payload, runOptions) => {
4700
+ // Bind the run to its backing Session so module-level helpers
4701
+ // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
4702
+ // resolve to this chat's `.in` / `.out` channels. Address
4703
+ // everywhere by `payload.chatId` (the session externalId) so the
4704
+ // agent's writes and the transport's reads converge on the same
4705
+ // S2 stream key + waitpoint key.
4706
+ //
4707
+ // The Session row is created server-side by `POST /sessions` (or
4708
+ // `chat.createStartSessionAction`) before this run is triggered.
4709
+ // No client-side upsert needed.
4710
+ locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
4711
+ locals.set(chatExternalIdKey, payload.chatId);
4712
+ locals.set(chatAgentRunContextKey, runOptions.ctx);
4713
+ locals.set(chatCustomAgentRunKey, true);
4714
+ if (parseClientData && parseClientDataSync) {
4715
+ locals.set(chatCustomAgentClientDataParserKey, {
4716
+ parse: parseClientData,
4717
+ parseSync: parseClientDataSync,
4718
+ });
4719
+ }
4720
+ if (onClientDataValidationError) {
4721
+ locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
4722
+ }
4723
+ locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
4724
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
4725
+ // trims `session.out` back to the previous turn boundary. Without
4726
+ // this the slot is undefined and the trim never runs, so `.out`
4727
+ // grows without bound for the whole custom-agent surface.
4728
+ locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
4729
+ markChatAgentRunForStreamsWarning();
4730
+ taskContext.setConversationId(payload.chatId);
4731
+ stampConversationIdOnActiveSpan(payload.chatId);
4732
+ await installChatInputRouter(payload.chatId, {
4733
+ resuming: Boolean(payload.continuation),
4734
+ });
4735
+ // A custom agent's loop is the customer's, so there is no exit site the
4736
+ // SDK controls. Perform a requested close when `run()` returns, whatever
4737
+ // shape the loop had. Idempotent, so the createSession iterator having
4738
+ // already closed on its own exit costs nothing.
4739
+ const withClose = async (result) => {
4740
+ try {
4741
+ return await result;
4742
+ }
4743
+ finally {
4744
+ await performChatClose();
4745
+ }
4746
+ };
4747
+ // Keep the schema-free path identical to the original custom-agent
4748
+ // wrapper, including when userRun starts executing.
4749
+ if (!parseClientData) {
4750
+ return withClose(userRun(payload, runOptions));
4751
+ }
4752
+ const isHandoverBoot = payload.trigger === "handover-prepare";
4753
+ const isMessagelessBoot = payload.trigger === "preload" ||
4754
+ (payload.continuation === true &&
4755
+ payload.message === undefined &&
4756
+ payload.trigger !== "action" &&
4757
+ payload.trigger !== "regenerate-message" &&
4758
+ !isHandoverBoot);
4759
+ const validated = await validateChatCustomAgentPayload(payload, {
4760
+ // Preload and continuation boots do not represent a submitted turn,
4761
+ // so there is no sender waiting for a terminal frame. Handover errors
4762
+ // must be written after the warm response flushes and signals below.
4763
+ writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
4764
+ });
4765
+ if (validated.ok) {
4766
+ return withClose(userRun(validated.payload, runOptions));
4767
+ }
4768
+ if (isHandoverBoot) {
4769
+ const signal = await waitForHandover({
4770
+ payload,
4771
+ timeout: "1h",
4772
+ spanName: "waiting for handover signal (invalid clientData)",
4773
+ });
4774
+ if (!signal || signal.kind === "handover-skip") {
4775
+ return;
4776
+ }
4777
+ // The head-start writer flushes before sending this signal. Writing
4778
+ // the terminal error now preserves stream order and closes the stitch.
4779
+ await writeChatCustomAgentClientDataErrorToStream(payload);
4780
+ return;
4781
+ }
4782
+ // The Session base payload is sticky across continuation runs. If it is
4783
+ // invalid, returning here would boot the same bad metadata again on the
4784
+ // next message. Stay attached and wait for a valid wire frame instead.
4785
+ const next = await messagesInput.waitWithIdleTimeout({
4786
+ idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
4787
+ timeout: "1h",
4788
+ spanName: "waiting for valid clientData",
4789
+ });
4790
+ if (!next.ok || next.output.trigger === "close") {
4791
+ return;
4792
+ }
4793
+ // Normal input frames omit run-level boot context. Carry it forward so
4794
+ // a continuation still tells the custom loop to restore prior state.
4795
+ const recoveredPayload = {
4796
+ ...next.output,
4797
+ continuation: next.output.continuation ?? payload.continuation,
4798
+ previousRunId: next.output.previousRunId ?? payload.previousRunId,
4799
+ sessionId: next.output.sessionId ?? payload.sessionId,
4800
+ idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
4801
+ };
4802
+ return withClose(userRun(recoveredPayload, runOptions));
4803
+ },
4804
+ });
4805
+ // Register clientDataSchema so the CLI converts it to JSONSchema
4806
+ // and stores it as payloadSchema — used by the Playground UI
4807
+ if (clientDataSchema) {
4808
+ resourceCatalog.updateTaskMetadata(options.id, {
4809
+ schema: clientDataSchema,
4810
+ });
4811
+ }
4812
+ return task;
4813
+ }
4814
+ function chatAgent(options) {
4815
+ 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, ...restOptions } = options;
4816
+ if (hydrateMessages) {
4817
+ if (storage) {
4818
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
4819
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
4820
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
4821
+ }
4822
+ if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
4823
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
4824
+ "`loadContext`. Both would own the model's context; keep one.");
4825
+ }
4826
+ warnHydrateMessagesDeprecatedOnce(options.id);
4827
+ }
4828
+ const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
4829
+ const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
4830
+ // chat.agent does not expose generic retry options (see docstring on
4831
+ // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
4832
+ // `oomMachine` is set we allow one retry on a larger machine; otherwise
4833
+ // we keep the historical no-retry default.
4834
+ const retry = oomMachine
4835
+ ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
4836
+ : { maxAttempts: 1 };
4837
+ const task = createTask({
4838
+ ...restOptions,
4839
+ retry,
4840
+ triggerSource: "agent",
4841
+ agentConfig: { type: "ai-sdk-chat" },
4842
+ run: async (payload, { signal: runSignal, ctx }) => {
3876
4843
  locals.set(chatAgentRunContextKey, ctx);
3877
4844
  locals.set(chatCustomAgentRunKey, false);
3878
4845
  // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
@@ -4011,6 +4978,69 @@ function chatAgent(options) {
4011
4978
  : undefined;
4012
4979
  let transcriptShadow = createTranscriptShadow([]);
4013
4980
  let bootTranscriptState = null;
4981
+ /**
4982
+ * The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
4983
+ * whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
4984
+ */
4985
+ let sessionChannelBinding;
4986
+ const channelDownloader = channels
4987
+ ? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
4988
+ ? sessionChannelBinding.handle
4989
+ : undefined)
4990
+ : undefined;
4991
+ locals.set(chatChannelDownloadKey, channelDownloader);
4992
+ /**
4993
+ * Tool calls that already have channel controls posted. A turn that resumes with one of
4994
+ * several pending calls resolved still carries the others as pending; without this the
4995
+ * controls would be posted again, and the model's final text would lose to them. A
4996
+ * continuation worker seeds it at boot from the calls still pending in the restored
4997
+ * transcript of a channel-bound session, since the worker before it posted their controls.
4998
+ */
4999
+ const channelRenderedToolCallIds = new Set();
5000
+ /** Serializes `lifecycle()` reports for the session's channel across turns. */
5001
+ const channelLifecycle = makeChannelLifecycleReporter();
5002
+ /**
5003
+ * Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
5004
+ * is dropped even when the transcript was hydrated again from a store that never saw the stop.
5005
+ */
5006
+ const channelStoppedToolCallIds = new Set();
5007
+ /** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
5008
+ let lastChannelTurnStartedAt;
5009
+ /** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
5010
+ let lastChannelTurnEndedAt;
5011
+ /** The turn reported its end state: the binding no longer marks the surface as working. */
5012
+ const clearChannelWorking = () => {
5013
+ if (!sessionChannelBinding?.working)
5014
+ return;
5015
+ const { working: _working, ...rest } = sessionChannelBinding;
5016
+ void _working;
5017
+ sessionChannelBinding = rest;
5018
+ };
5019
+ /** The turn's reply stream ended: the binding no longer names it for recovery. */
5020
+ const clearOpenReplyStream = () => {
5021
+ if (!sessionChannelBinding?.stream)
5022
+ return;
5023
+ const { stream: _stream, ...rest } = sessionChannelBinding;
5024
+ void _stream;
5025
+ sessionChannelBinding = rest;
5026
+ };
5027
+ /** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
5028
+ const reportChannelClosed = async () => {
5029
+ const binding = sessionChannelBinding;
5030
+ if (!binding)
5031
+ return;
5032
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5033
+ await channelLifecycle.report(connector, "closed", {
5034
+ handle: binding.handle,
5035
+ bound: true,
5036
+ firstTurn: false,
5037
+ });
5038
+ };
5039
+ /** Provenance of the turn or action being processed, for the storage context. */
5040
+ let currentTurnSource = "client";
5041
+ let currentTurnConnectorId;
5042
+ /** True once `onAction` returned `chat.turn()` for the action being processed. */
5043
+ let turnIsActionTurn = false;
4014
5044
  /**
4015
5045
  * True while the model lane holds a compaction summary, so it cannot be
4016
5046
  * rebuilt from the transcript and has to be persisted as state. Reset
@@ -4073,9 +5103,14 @@ function chatAgent(options) {
4073
5103
  const transcriptIds = new Set(opts.messages.map((message) => message.id));
4074
5104
  const markerIds = new Set(steeringMarkers(opts.messages).map((marker) => marker.id));
4075
5105
  const steering = [...(locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
4076
- const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0 || steering.length > 0
5106
+ const runtimeState = laneCompacted ||
5107
+ laneInjections.length > 0 ||
5108
+ queued.length > 0 ||
5109
+ steering.length > 0 ||
5110
+ sessionChannelBinding
4077
5111
  ? {
4078
5112
  v: 1,
5113
+ ...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
4079
5114
  ...(laneCompacted
4080
5115
  ? {
4081
5116
  compaction: {
@@ -4105,7 +5140,9 @@ function chatAgent(options) {
4105
5140
  chatId: payload.chatId,
4106
5141
  clientData: opts.clientData,
4107
5142
  turn: opts.turn,
4108
- trigger: opts.trigger,
5143
+ trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
5144
+ source: currentTurnSource,
5145
+ ...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
4109
5146
  runId: ctx.run.id,
4110
5147
  ctx,
4111
5148
  }, {
@@ -4188,6 +5225,9 @@ function chatAgent(options) {
4188
5225
  let replayedPartial;
4189
5226
  let replayedPartialRaw;
4190
5227
  let replayedInTail = [];
5228
+ // Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
5229
+ // they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
5230
+ let replayedChannelInTail = [];
4191
5231
  // Wire payloads to dispatch as turns before the regular session.in
4192
5232
  // pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
4193
5233
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
@@ -4318,9 +5358,11 @@ function chatAgent(options) {
4318
5358
  }
4319
5359
  bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
4320
5360
  try {
4321
- replayedInTail = await replaySessionInTail(payload.chatId, {
5361
+ const tail = await replaySessionInTailWithChannels(payload.chatId, {
4322
5362
  lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
4323
5363
  });
5364
+ replayedInTail = tail.messages;
5365
+ replayedChannelInTail = tail.channelEvents;
4324
5366
  }
4325
5367
  catch (error) {
4326
5368
  logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
@@ -4341,7 +5383,10 @@ function chatAgent(options) {
4341
5383
  }
4342
5384
  // ── session.in router ──────────────────────────────────────────
4343
5385
  //
4344
- const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
5386
+ const recoveredSeqNums = [
5387
+ ...replayedInTail.map((r) => r.seqNum),
5388
+ ...replayedChannelInTail.map((r) => r.seqNum),
5389
+ ];
4345
5390
  await installChatInputRouter(payload.chatId, {
4346
5391
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4347
5392
  recoveredSeqNums,
@@ -4358,6 +5403,9 @@ function chatAgent(options) {
4358
5403
  // predecessor chose to end before processing the message;
4359
5404
  // those route through the normal continuation-wait path.
4360
5405
  const hasRecoveredState = partialAssistant !== undefined;
5406
+ if (couldHavePriorState && hasRecoveredState) {
5407
+ locals.set(chatChannelRecoveryPendingKey, { value: true });
5408
+ }
4361
5409
  let hookChain;
4362
5410
  let hookRecoveredTurns;
4363
5411
  let hookBeforeBoot;
@@ -4428,10 +5476,18 @@ function chatAgent(options) {
4428
5476
  // user is re-dispatched as a fresh turn instead.
4429
5477
  let seedChain;
4430
5478
  let recoveredEntries;
5479
+ // The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
5480
+ // which only makes sense when that record is the browser message it was answering. When a
5481
+ // channel delivery is older than every browser message, the partial belongs to it instead, and
5482
+ // splicing the first browser message ahead of it would reorder the conversation.
5483
+ const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
5484
+ const partialFollowsBrowserMessage = partialAssistant !== undefined &&
5485
+ inFlightUsers.length > 1 &&
5486
+ (replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
4431
5487
  if (hookChain !== undefined) {
4432
5488
  seedChain = hookChain;
4433
5489
  }
4434
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5490
+ else if (partialFollowsBrowserMessage) {
4435
5491
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
4436
5492
  }
4437
5493
  else {
@@ -4451,7 +5507,7 @@ function chatAgent(options) {
4451
5507
  seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4452
5508
  }));
4453
5509
  }
4454
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5510
+ else if (partialFollowsBrowserMessage) {
4455
5511
  recoveredEntries = replayedInTail
4456
5512
  .slice(1)
4457
5513
  .map((r) => ({ message: r.message, seqNum: r.seqNum }));
@@ -4488,6 +5544,7 @@ function chatAgent(options) {
4488
5544
  metadataById.set(entry.message.id, entry.metadata);
4489
5545
  }
4490
5546
  const dispatchedRecoveredSeqs = new Set();
5547
+ const toInject = [];
4491
5548
  for (const { message: msg, seqNum } of recoveredEntries) {
4492
5549
  if (wireMessageId && msg.id === wireMessageId)
4493
5550
  continue;
@@ -4499,18 +5556,42 @@ function chatAgent(options) {
4499
5556
  sessionId: payload.sessionId,
4500
5557
  metadata: recoveredMetadata,
4501
5558
  trigger: "submit-message",
4502
- message: msg,
4503
- messageId: msg.id,
5559
+ message: msg,
5560
+ messageId: msg.id,
5561
+ continuation: payload.continuation,
5562
+ previousRunId: payload.previousRunId,
5563
+ };
5564
+ toInject.push({ injectedPayload, seqNum });
5565
+ }
5566
+ // Channel deliveries the predecessor never processed dispatch too, interleaved with the
5567
+ // browser messages by seq_num so a Slack message that arrived before a browser message
5568
+ // still runs before it. A delivery the boot payload itself carries is not queued twice.
5569
+ const wireDeliveryId = payload.channelEvent
5570
+ ?.deliveryId;
5571
+ for (const entry of replayedChannelInTail) {
5572
+ const entryDeliveryId = entry.channelEvent.deliveryId;
5573
+ if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
5574
+ continue;
5575
+ const injectedPayload = {
5576
+ chatId: payload.chatId,
5577
+ sessionId: payload.sessionId,
5578
+ metadata: entry.metadata ?? payload.metadata,
5579
+ trigger: "submit-message",
5580
+ channelEvent: entry.channelEvent,
4504
5581
  continuation: payload.continuation,
4505
5582
  previousRunId: payload.previousRunId,
4506
5583
  };
5584
+ toInject.push({ injectedPayload, seqNum: entry.seqNum });
5585
+ }
5586
+ toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
5587
+ for (const { injectedPayload, seqNum } of toInject) {
4507
5588
  bootInjectedQueue.push(injectedPayload);
4508
5589
  if (seqNum !== undefined) {
4509
5590
  recoveredSeqByPayload.set(injectedPayload, seqNum);
4510
5591
  dispatchedRecoveredSeqs.add(seqNum);
4511
5592
  }
4512
5593
  }
4513
- for (const entry of replayedInTail) {
5594
+ for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
4514
5595
  if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4515
5596
  chatInputRouter().settleRecovered(entry.seqNum);
4516
5597
  }
@@ -4562,6 +5643,38 @@ function chatAgent(options) {
4562
5643
  accumulatedMessages = restored.messages;
4563
5644
  laneCompacted = restored.compacted;
4564
5645
  laneInjections = restored.injections;
5646
+ sessionChannelBinding = bootRuntimeState?.channelBinding;
5647
+ const interruptedStream = sessionChannelBinding?.stream;
5648
+ if (interruptedStream && sessionChannelBinding) {
5649
+ const binding = sessionChannelBinding;
5650
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5651
+ clearOpenReplyStream();
5652
+ if (connector?.endInterruptedStream) {
5653
+ try {
5654
+ await connector.endInterruptedStream(interruptedStream, {
5655
+ handle: binding.handle,
5656
+ bound: true,
5657
+ });
5658
+ }
5659
+ catch (error) {
5660
+ logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
5661
+ }
5662
+ }
5663
+ }
5664
+ if (sessionChannelBinding?.working) {
5665
+ const binding = sessionChannelBinding;
5666
+ clearChannelWorking();
5667
+ await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
5668
+ }
5669
+ if (sessionChannelBinding) {
5670
+ for (const message of accumulatedUIMessages) {
5671
+ if (message.role !== "assistant")
5672
+ continue;
5673
+ for (const call of pendingToolCallsInMessage(message)) {
5674
+ channelRenderedToolCallIds.add(call.toolCallId);
5675
+ }
5676
+ }
5677
+ }
4565
5678
  if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4566
5679
  locals.set(chatBackgroundQueueKey, [
4567
5680
  ...(locals.get(chatBackgroundQueueKey) ?? []),
@@ -4948,26 +6061,432 @@ function chatAgent(options) {
4948
6061
  // Declared here so the finally can detach it — a handler leaked past
4949
6062
  // its turn duplicates every mid-stream message into the shared buffer.
4950
6063
  let turnMsgSub;
6064
+ let turnControlSub;
4951
6065
  let capturedPartialResponse;
4952
6066
  let responseCommitted = false;
4953
6067
  const turnBufferedChunks = [];
6068
+ let channelConn;
6069
+ let channelWorkingReaction;
6070
+ let channelWireEvent;
6071
+ /** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
6072
+ let channelHandle;
6073
+ /** True when no channel delivery started this turn and egress goes through the session binding. */
6074
+ let boundTurn = false;
6075
+ let channelAckRef;
6076
+ let channelStreamEditor;
6077
+ let droppedStaleInteraction = false;
6078
+ /** True when the connector mapped this delivery to an empty message: no turn runs. */
6079
+ let droppedEmptyMessage = false;
6080
+ let droppedUnknownConnector = false;
6081
+ /** True when this delivery was a stop the connector drops while idle: no turn runs. */
6082
+ let droppedIdleControl = false;
6083
+ /** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
6084
+ let channelReplyStream;
6085
+ /** True once this turn reported `working`, so its end reports the outcome. */
6086
+ let channelLifecycleStarted = false;
6087
+ /** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
6088
+ const channelTurnStartedAt = Date.now();
6089
+ /** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
6090
+ let channelTurnRan = false;
6091
+ /** True when this turn resolves a pending tool call from a channel interaction callback. */
6092
+ let channelInteractionResolved = false;
6093
+ let channelFinalAnswerPosted = false;
6094
+ let channelFinalSendFailed = false;
6095
+ /** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
6096
+ let channelStreamErrorText;
6097
+ /** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
6098
+ let channelTurnText;
4954
6099
  try {
4955
6100
  // Extract turn-level context before entering the span. Slim
4956
6101
  // wire: at most one delta message per record. `headStartMessages`
4957
6102
  // is consumed at boot only (via `payload.headStartMessages`)
4958
6103
  // and intentionally discarded here.
4959
- const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, ...restWire } = currentWirePayload;
6104
+ const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
6105
+ let actionChannel;
6106
+ if (clickedAction !== undefined) {
6107
+ const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
6108
+ const click = clicked;
6109
+ actionChannel = {
6110
+ connectorId: click.connectorId,
6111
+ provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
6112
+ event: click.event,
6113
+ deliveryId: click.deliveryId,
6114
+ };
6115
+ currentWirePayload = {
6116
+ ...withoutChannelEvent,
6117
+ trigger: "action",
6118
+ action: clickedAction,
6119
+ actionSource: "channel",
6120
+ };
6121
+ }
6122
+ const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
4960
6123
  void _hsm;
4961
- const incomingMessages = incomingMessage
4962
- ? [incomingMessage]
6124
+ channelWireEvent = wireChannelEvent;
6125
+ let effectiveIncomingMessage = incomingMessage;
6126
+ const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
6127
+ turnClientData = clientData;
6128
+ turnIsActionTurn = false;
6129
+ currentTurnSource = wireChannelEvent
6130
+ ? "channel"
6131
+ : currentWirePayload.actionSource === "webhook"
6132
+ ? "webhook"
6133
+ : "client";
6134
+ currentTurnConnectorId = undefined;
6135
+ if (wireChannelEvent) {
6136
+ channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
6137
+ if (channelConn) {
6138
+ currentTurnConnectorId = channelConn.id;
6139
+ let idleControl = null;
6140
+ if (channelConn.onControl) {
6141
+ try {
6142
+ idleControl = channelConn.onControl(wireChannelEvent.event);
6143
+ }
6144
+ catch (controlError) {
6145
+ logger.warn("chat.agent: channel onControl threw; treating as a message", {
6146
+ error: controlError,
6147
+ });
6148
+ }
6149
+ }
6150
+ const stopForEndedTurn = idleControl?.at !== undefined &&
6151
+ lastChannelTurnEndedAt !== undefined &&
6152
+ idleControl.at < lastChannelTurnEndedAt;
6153
+ droppedIdleControl =
6154
+ idleControl?.kind === "stop" &&
6155
+ (idleControl.whenIdle === "drop" || stopForEndedTurn);
6156
+ // Bind the session to this connector so a later turn that no channel delivery
6157
+ // started (an action turn, a browser message) can still answer into the thread.
6158
+ if (channelConn.bind && !droppedIdleControl) {
6159
+ try {
6160
+ const handle = channelConn.bind(wireChannelEvent.event);
6161
+ if (handle !== null && handle !== undefined) {
6162
+ const controls = sessionChannelBinding?.connectorId === channelConn.id
6163
+ ? sessionChannelBinding.controls
6164
+ : undefined;
6165
+ sessionChannelBinding = {
6166
+ connectorId: channelConn.id,
6167
+ handle,
6168
+ boundAt: new Date().toISOString(),
6169
+ deliveryId: wireChannelEvent.deliveryId,
6170
+ ...(controls ? { controls } : {}),
6171
+ };
6172
+ }
6173
+ }
6174
+ catch (bindError) {
6175
+ logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
6176
+ error: bindError,
6177
+ });
6178
+ }
6179
+ }
6180
+ channelHandle =
6181
+ sessionChannelBinding?.connectorId === channelConn.id
6182
+ ? sessionChannelBinding.handle
6183
+ : undefined;
6184
+ const rawInteraction = droppedIdleControl
6185
+ ? null
6186
+ : (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
6187
+ let interaction = rawInteraction
6188
+ ? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
6189
+ : null;
6190
+ const accumulatorHasInteractionToolCall = interaction != null &&
6191
+ accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
6192
+ if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
6193
+ try {
6194
+ const hydratedForInteraction = (await loadContextHook({
6195
+ chatId: currentWirePayload.chatId,
6196
+ turn,
6197
+ trigger: "submit-message",
6198
+ incomingMessages: [],
6199
+ previousMessages: [...accumulatedUIMessages],
6200
+ clientData,
6201
+ continuation,
6202
+ previousRunId,
6203
+ }));
6204
+ accumulatedUIMessages = hydratedForInteraction;
6205
+ accumulatedMessages = await toModelMessages(hydratedForInteraction);
6206
+ laneCompacted = false;
6207
+ laneInjections = [];
6208
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6209
+ if (!interaction) {
6210
+ interaction =
6211
+ resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
6212
+ }
6213
+ }
6214
+ catch (hydrateError) {
6215
+ logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
6216
+ }
6217
+ }
6218
+ const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
6219
+ ? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
6220
+ source: "channel",
6221
+ connectorId: channelConn.id,
6222
+ provider: channelConn.source,
6223
+ deliveryId: wireChannelEvent.deliveryId,
6224
+ })
6225
+ : undefined;
6226
+ const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
6227
+ ? undefined
6228
+ : channelConn.inbound(wireChannelEvent.event);
6229
+ if (droppedIdleControl) {
6230
+ const staleStop = idleControl?.at !== undefined &&
6231
+ lastChannelTurnStartedAt !== undefined &&
6232
+ idleControl.at < lastChannelTurnStartedAt;
6233
+ if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
6234
+ try {
6235
+ const hydratedForStop = (await loadContextHook({
6236
+ chatId: currentWirePayload.chatId,
6237
+ turn,
6238
+ trigger: "submit-message",
6239
+ incomingMessages: [],
6240
+ previousMessages: [],
6241
+ clientData,
6242
+ continuation,
6243
+ previousRunId,
6244
+ }));
6245
+ accumulatedUIMessages = hydratedForStop;
6246
+ accumulatedMessages = await toModelMessages(hydratedForStop);
6247
+ laneCompacted = false;
6248
+ laneInjections = [];
6249
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6250
+ }
6251
+ catch (hydrateError) {
6252
+ logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
6253
+ error: hydrateError,
6254
+ });
6255
+ }
6256
+ }
6257
+ let lastAssistantIndex = -1;
6258
+ for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
6259
+ if (accumulatedUIMessages[i].role === "assistant") {
6260
+ lastAssistantIndex = i;
6261
+ break;
6262
+ }
6263
+ }
6264
+ const lastAssistant = lastAssistantIndex === -1
6265
+ ? undefined
6266
+ : accumulatedUIMessages[lastAssistantIndex];
6267
+ const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
6268
+ ? pendingToolCallsInMessage(lastAssistant)
6269
+ : [];
6270
+ if (lastAssistant && stoppedCalls.length > 0) {
6271
+ const errorText = idleControl?.message || "stopped";
6272
+ const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
6273
+ if (stoppedMessage) {
6274
+ accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
6275
+ const pausedRun = await toModelMessages([
6276
+ stripProviderMetadata(lastAssistant),
6277
+ ]);
6278
+ if (pausedRun.length === 0) {
6279
+ accumulatedMessages = [
6280
+ ...accumulatedMessages,
6281
+ ...(await toModelMessages([stoppedMessage])),
6282
+ ];
6283
+ }
6284
+ else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
6285
+ logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
6286
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
6287
+ laneCompacted = false;
6288
+ laneInjections = [];
6289
+ }
6290
+ for (const call of stoppedCalls)
6291
+ channelStoppedToolCallIds.add(call.toolCallId);
6292
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6293
+ }
6294
+ const refs = sessionChannelBinding?.connectorId === channelConn.id
6295
+ ? sessionChannelBinding.controls
6296
+ : undefined;
6297
+ const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
6298
+ for (const call of stoppedCalls) {
6299
+ channelRenderedToolCallIds.delete(call.toolCallId);
6300
+ }
6301
+ if (refs && sessionChannelBinding) {
6302
+ const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
6303
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6304
+ void _controls;
6305
+ sessionChannelBinding =
6306
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6307
+ }
6308
+ await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
6309
+ if (channelConn.cancelInteraction) {
6310
+ try {
6311
+ await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
6312
+ }
6313
+ catch (cancelError) {
6314
+ logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
6315
+ error: cancelError,
6316
+ });
6317
+ }
6318
+ }
6319
+ await channelLifecycle.report(channelConn, "idle", {
6320
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6321
+ firstTurn: false,
6322
+ });
6323
+ }
6324
+ }
6325
+ else if (resolutionMessage) {
6326
+ effectiveIncomingMessage = resolutionMessage;
6327
+ channelInteractionResolved = true;
6328
+ const resolvedCallId = interaction?.toolCallId;
6329
+ const controlRefs = sessionChannelBinding?.controls;
6330
+ if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
6331
+ const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
6332
+ void _resolved;
6333
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6334
+ void _controls;
6335
+ sessionChannelBinding =
6336
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6337
+ }
6338
+ if (interaction && channelConn.finalizeInteraction) {
6339
+ try {
6340
+ await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
6341
+ }
6342
+ catch (finalizeError) {
6343
+ logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
6344
+ error: finalizeError,
6345
+ });
6346
+ }
6347
+ }
6348
+ }
6349
+ else if (rawInteraction) {
6350
+ droppedStaleInteraction = true;
6351
+ logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
6352
+ deliveryId: wireChannelEvent.deliveryId,
6353
+ });
6354
+ }
6355
+ else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
6356
+ droppedEmptyMessage = true;
6357
+ logger.debug("chat.agent: dropping a channel delivery with an empty message", {
6358
+ deliveryId: wireChannelEvent.deliveryId,
6359
+ });
6360
+ }
6361
+ else {
6362
+ // A session that starts on a conversation with history: let the connector supply
6363
+ // it, so the agent answers with the context a reader of the thread has.
6364
+ if (channelConn.seed && accumulatedUIMessages.length === 0) {
6365
+ try {
6366
+ const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
6367
+ event: wireChannelEvent.event,
6368
+ deliveryId: wireChannelEvent.deliveryId,
6369
+ }), {
6370
+ source: "channel",
6371
+ connectorId: channelConn.id,
6372
+ provider: channelConn.source,
6373
+ deliveryId: wireChannelEvent.deliveryId,
6374
+ });
6375
+ if (seeded.length > 0) {
6376
+ accumulatedUIMessages = seeded;
6377
+ accumulatedMessages = await toModelMessages(seeded);
6378
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6379
+ }
6380
+ }
6381
+ catch (seedError) {
6382
+ logger.warn("chat.agent: channel seed() failed; starting the session empty", {
6383
+ error: seedError,
6384
+ });
6385
+ }
6386
+ }
6387
+ effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
6388
+ source: "channel",
6389
+ connectorId: channelConn.id,
6390
+ provider: channelConn.source,
6391
+ deliveryId: wireChannelEvent.deliveryId,
6392
+ ...(wireChannelEvent.externalDeliveryId
6393
+ ? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
6394
+ : {}),
6395
+ });
6396
+ if (channelConn.send && channelConn.ack) {
6397
+ const recoveryPending = locals.get(chatChannelRecoveryPendingKey);
6398
+ const recovered = recoveryPending?.value === true;
6399
+ if (recovered)
6400
+ recoveryPending.value = false;
6401
+ const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
6402
+ if (ackMessage) {
6403
+ try {
6404
+ const ackResult = await channelConn.send(ackMessage, {
6405
+ event: wireChannelEvent.event,
6406
+ handle: channelHandle,
6407
+ deliveryId: wireChannelEvent.deliveryId,
6408
+ mode: channelConn.delivery,
6409
+ final: false,
6410
+ bound: false,
6411
+ });
6412
+ channelAckRef = ackResult?.ref;
6413
+ }
6414
+ catch (ackError) {
6415
+ logger.warn("chat.agent: channel ack post failed; continuing", {
6416
+ error: ackError,
6417
+ });
6418
+ }
6419
+ }
6420
+ }
6421
+ try {
6422
+ channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
6423
+ if (channelWorkingReaction) {
6424
+ await applyChannelReaction(channelConn, wireChannelEvent, {
6425
+ name: channelWorkingReaction,
6426
+ });
6427
+ }
6428
+ }
6429
+ catch (reactionError) {
6430
+ logger.warn("chat.agent: channel working reaction failed", {
6431
+ error: reactionError,
6432
+ });
6433
+ }
6434
+ }
6435
+ }
6436
+ else {
6437
+ droppedUnknownConnector = true;
6438
+ logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
6439
+ connectorId: wireChannelEvent.connectorId,
6440
+ deliveryId: wireChannelEvent.deliveryId,
6441
+ });
6442
+ }
6443
+ }
6444
+ if (!wireChannelEvent &&
6445
+ sessionChannelBinding &&
6446
+ currentWirePayload.trigger !== "close" &&
6447
+ currentWirePayload.trigger !== "preload") {
6448
+ // No channel delivery started this turn, but the session is bound to a channel: answer
6449
+ // into it. Reactions need the triggering message, so a bound turn adds none; its reply,
6450
+ // any error and any approval controls go to the bound thread.
6451
+ const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
6452
+ if (bound?.send) {
6453
+ channelConn = bound;
6454
+ channelHandle = sessionChannelBinding.handle;
6455
+ boundTurn = true;
6456
+ currentTurnConnectorId = bound.id;
6457
+ }
6458
+ }
6459
+ const incomingMessages = effectiveIncomingMessage
6460
+ ? [effectiveIncomingMessage]
4963
6461
  : [];
4964
6462
  // Cleaning happens once here so `extractLastUserMessageText` and
4965
6463
  // every downstream consumer see the same message shape — and
4966
6464
  // `cleanupAbortedParts` no longer has to be re-applied below.
4967
6465
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4968
- const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4969
- turnClientData = clientData;
4970
6466
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
6467
+ channelTurnRan =
6468
+ !!channelConn &&
6469
+ !!(wireChannelEvent || boundTurn) &&
6470
+ currentWirePayload.trigger !== "action" &&
6471
+ !droppedIdleControl &&
6472
+ !droppedStaleInteraction &&
6473
+ !droppedEmptyMessage;
6474
+ const startChannelLifecycle = async () => {
6475
+ lastChannelTurnStartedAt = channelTurnStartedAt;
6476
+ if (!channelConn?.lifecycle)
6477
+ return;
6478
+ channelLifecycleStarted = true;
6479
+ if (sessionChannelBinding?.connectorId === channelConn.id) {
6480
+ sessionChannelBinding = { ...sessionChannelBinding, working: true };
6481
+ }
6482
+ await channelLifecycle.report(channelConn, "working", {
6483
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6484
+ firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
6485
+ ...(lastUserMessage ? { prompt: lastUserMessage } : {}),
6486
+ });
6487
+ };
6488
+ if (channelTurnRan)
6489
+ await startChannelLifecycle();
4971
6490
  // Actions are not turns. They use a different span name
4972
6491
  // and don't carry a turn.number. Branched on at `isAction`.
4973
6492
  const isAction = currentWirePayload.trigger === "action";
@@ -5023,6 +6542,10 @@ function chatAgent(options) {
5023
6542
  // (incl. parsed clientData) exists, so every toModelMessages
5024
6543
  // call this turn can re-apply tool `toModelOutput`.
5025
6544
  await resolveTurnTools();
6545
+ // The channel surface for this turn, handed to run() and reachable from a tool's
6546
+ // execute through chat.surface(), so a connector can ship tools the model drives.
6547
+ const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
6548
+ locals.set(chatChannelSurfaceKey, channelSurface);
5026
6549
  // Per-turn stop controller (reset each turn)
5027
6550
  const stopController = new AbortController();
5028
6551
  currentStopController = stopController;
@@ -5044,12 +6567,19 @@ function chatAgent(options) {
5044
6567
  ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
5045
6568
  const msg = record.data
5046
6569
  .payload;
6570
+ const wireChannel = msg
6571
+ .channelEvent;
6572
+ const steeredChannel = wireChannel != null
6573
+ ? channelSteeringMessage(channels, wireChannel, msg.messageId)
6574
+ : undefined;
6575
+ if (wireChannel != null && !steeredChannel)
6576
+ return;
5047
6577
  // Slim wire: at most one delta message per record. The
5048
6578
  // pendingMessages handler reads `msg.message` directly
5049
6579
  // instead of slicing an array — a wire record arrives
5050
6580
  // with the new user message in `.message`, or no message
5051
6581
  // at all (regenerate / preload / close / handover-prepare).
5052
- const lastUIMessage = msg.message;
6582
+ const lastUIMessage = (steeredChannel?.message ?? msg.message);
5053
6583
  if (lastUIMessage) {
5054
6584
  if (pmConfig.onReceived) {
5055
6585
  try {
@@ -5070,7 +6600,10 @@ function chatAgent(options) {
5070
6600
  queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
5071
6601
  return;
5072
6602
  }
5073
- const modelMsgs = await toModelMessages([lastUIMessage]);
6603
+ const converted = await toModelMessages([lastUIMessage]);
6604
+ const modelMsgs = steeredChannel && channelDownloader
6605
+ ? await resolveChannelFiles(converted, channelDownloader)
6606
+ : converted;
5074
6607
  queue.push({
5075
6608
  uiMessage: lastUIMessage,
5076
6609
  modelMessages: modelMsgs,
@@ -5085,6 +6618,52 @@ function chatAgent(options) {
5085
6618
  })
5086
6619
  : undefined;
5087
6620
  turnMsgSub = msgSub;
6621
+ /**
6622
+ * Channel deliveries while this turn runs. A delivery the connector maps to `stop`
6623
+ * aborts this turn through the same controller the stop input uses, and is taken
6624
+ * off the route so it does not also run as a turn. Any other message is
6625
+ * acknowledged through the connector's `received` as soon as it arrives, and left
6626
+ * queued for steering or the next turn.
6627
+ */
6628
+ const controlSub = channels?.some((c) => c.onControl || c.received)
6629
+ ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
6630
+ const msg = record.data
6631
+ .payload;
6632
+ const ce = msg.channelEvent;
6633
+ if (!ce)
6634
+ return;
6635
+ const conn = channels?.find((c) => c.id === ce.connectorId);
6636
+ if (!conn)
6637
+ return;
6638
+ let control = null;
6639
+ try {
6640
+ control = conn.onControl?.(ce.event) ?? null;
6641
+ }
6642
+ catch (controlError) {
6643
+ logger.warn("chat.agent: channel onControl threw; treating as a message", {
6644
+ error: controlError,
6645
+ });
6646
+ }
6647
+ if (!control) {
6648
+ acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
6649
+ ? sessionChannelBinding.handle
6650
+ : undefined);
6651
+ return;
6652
+ }
6653
+ if (control.kind !== "stop")
6654
+ return;
6655
+ const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
6656
+ if (!earlierTurnStop && !stopController.signal.aborted) {
6657
+ stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
6658
+ }
6659
+ // Observers are offered a record before it is queued, so the take has to
6660
+ // wait a tick or it finds nothing and the stop runs again as a turn.
6661
+ void Promise.resolve().then(() => {
6662
+ chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
6663
+ });
6664
+ })
6665
+ : undefined;
6666
+ turnControlSub = controlSub;
5088
6667
  // Track new messages for this turn (user input + assistant response).
5089
6668
  const turnNewModelMessages = [];
5090
6669
  const turnNewUIMessages = [];
@@ -5114,7 +6693,8 @@ function chatAgent(options) {
5114
6693
  let actionChangedHistory = false;
5115
6694
  if (isAction) {
5116
6695
  // Parse and validate the action payload
5117
- const parsedAction = parseAction
6696
+ const isWebhookAction = currentWirePayload.actionSource === "webhook";
6697
+ const parsedAction = parseAction && !isWebhookAction
5118
6698
  ? await parseAction(currentWirePayload.action)
5119
6699
  : currentWirePayload.action;
5120
6700
  // Hydrate messages from backend if configured
@@ -5155,6 +6735,7 @@ function chatAgent(options) {
5155
6735
  clientData,
5156
6736
  uiMessages: accumulatedUIMessages,
5157
6737
  messages: accumulatedMessages,
6738
+ ...(actionChannel ? { channel: actionChannel } : {}),
5158
6739
  });
5159
6740
  }, {
5160
6741
  attributes: {
@@ -5189,7 +6770,11 @@ function chatAgent(options) {
5189
6770
  // snapshot + `session.out` replay (or `hydrateMessages`,
5190
6771
  // which also fires per-turn below). Per-turn handling is
5191
6772
  // therefore a delta merge, not a full-history reset.
5192
- if (currentWirePayload.trigger !== "action") {
6773
+ if (currentWirePayload.trigger !== "action" &&
6774
+ !droppedStaleInteraction &&
6775
+ !droppedEmptyMessage &&
6776
+ !droppedUnknownConnector &&
6777
+ !droppedIdleControl) {
5193
6778
  let cleanedUIMessages = cleanedIncomingMessages;
5194
6779
  // Turn-0 head-start with hydrateMessages: the boot seeding from
5195
6780
  // `payload.headStartMessages` is non-hydrate-only, so ship the
@@ -5441,17 +7026,41 @@ function chatAgent(options) {
5441
7026
  // calling the model would prefill its own last reply. Keyed on
5442
7027
  // the model tail, so a `tool`-terminated chain (a merged tool
5443
7028
  // approval) still runs.
5444
- const isNoOpTurn = !isAction &&
5445
- !splicedHandoverPartial &&
5446
- currentWirePayload.trigger === "submit-message" &&
5447
- turnNewUIMessages.length === 0 &&
5448
- accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
7029
+ // A channel delivery the run decided not to turn into a turn: a stale
7030
+ // interaction callback (no pending tool call matched) or an event for
7031
+ // a connector this agent does not list. Nothing to answer either way.
7032
+ const isDroppedChannelDelivery = droppedStaleInteraction ||
7033
+ droppedEmptyMessage ||
7034
+ droppedUnknownConnector ||
7035
+ droppedIdleControl;
7036
+ // A resolved interaction is never a no-op: the assistant may already have text after
7037
+ // the pending call, so the lane ends on an assistant message, but the model has not
7038
+ // seen the decision yet.
7039
+ const isNoOpTurn = isDroppedChannelDelivery ||
7040
+ (!isAction &&
7041
+ !splicedHandoverPartial &&
7042
+ !channelInteractionResolved &&
7043
+ currentWirePayload.trigger === "submit-message" &&
7044
+ turnNewUIMessages.length === 0 &&
7045
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
5449
7046
  if (isNoOpTurn) {
5450
7047
  msgSub?.off();
5451
- logger.warn("chat.agent: turn added no new user message; skipping the model", {
5452
- chatId: currentWirePayload.chatId,
5453
- messageId: currentWirePayload.messageId,
5454
- });
7048
+ turnControlSub?.off();
7049
+ if (channelTurnRan)
7050
+ lastChannelTurnEndedAt = Date.now();
7051
+ clearChannelWorking();
7052
+ if (channelLifecycleStarted) {
7053
+ await channelLifecycle.report(channelConn, "idle", {
7054
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7055
+ firstTurn: false,
7056
+ });
7057
+ }
7058
+ if (!isDroppedChannelDelivery) {
7059
+ logger.warn("chat.agent: turn added no new user message; skipping the model", {
7060
+ chatId: currentWirePayload.chatId,
7061
+ messageId: currentWirePayload.messageId,
7062
+ });
7063
+ }
5455
7064
  settleRecoveredTurn(currentWirePayload);
5456
7065
  await writeTurnCompleteChunk(currentWirePayload.chatId);
5457
7066
  // Not a turn — don't consume an iteration.
@@ -5467,6 +7076,24 @@ function chatAgent(options) {
5467
7076
  // sees the same `turn` value — actions don't count.
5468
7077
  if (isAction) {
5469
7078
  if (isActionTurn(actionResult)) {
7079
+ if (actionResult.message !== undefined) {
7080
+ const asked = (typeof actionResult.message === "string"
7081
+ ? {
7082
+ id: generateMessageId(),
7083
+ role: "user",
7084
+ parts: [{ type: "text", text: actionResult.message }],
7085
+ }
7086
+ : actionResult.message.id
7087
+ ? actionResult.message
7088
+ : { ...actionResult.message, id: generateMessageId() });
7089
+ accumulatedUIMessages = [...accumulatedUIMessages, asked];
7090
+ accumulatedMessages = [
7091
+ ...accumulatedMessages,
7092
+ ...(await toModelMessages([asked])),
7093
+ ];
7094
+ locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
7095
+ actionChangedHistory = true;
7096
+ }
5470
7097
  // Persist the edit before the turn starts, so a turn that is
5471
7098
  // cancelled or runs out of memory continues from the edited
5472
7099
  // history rather than from the snapshot the edit replaced.
@@ -5475,6 +7102,11 @@ function chatAgent(options) {
5475
7102
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5476
7103
  }
5477
7104
  actionTurn = true;
7105
+ turnIsActionTurn = true;
7106
+ if (boundTurn && channelConn) {
7107
+ channelTurnRan = true;
7108
+ await startChannelLifecycle();
7109
+ }
5478
7110
  }
5479
7111
  else if (actionResult !== undefined) {
5480
7112
  throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
@@ -5483,6 +7115,7 @@ function chatAgent(options) {
5483
7115
  }
5484
7116
  else {
5485
7117
  msgSub?.off();
7118
+ turnControlSub?.off();
5486
7119
  if (actionChangedHistory) {
5487
7120
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5488
7121
  }
@@ -5684,12 +7317,87 @@ function chatAgent(options) {
5684
7317
  }
5685
7318
  else {
5686
7319
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
7320
+ const runMessages = channelDownloader
7321
+ ? await resolveChannelFiles(preparedMessages, channelDownloader)
7322
+ : preparedMessages;
7323
+ // Channel observers for this turn are built before run(). The progress
7324
+ // observer and the native reply stream are shared with a manual chat.pipe()
7325
+ // inside run() through the turn tap and stay open across several pipes; the
7326
+ // ack stream editor only rides the automatic pipe, because a manual pipe
7327
+ // captures no response and posts no final reply that would supersede a
7328
+ // partial edit. The finally of this turn stops all of them and clears the tap;
7329
+ // the turn boundary ends the native stream.
7330
+ if (channelConn) {
7331
+ const observers = [];
7332
+ const tapObservers = [];
7333
+ if (channelConn.delivery === "stream" &&
7334
+ channelConn.stream &&
7335
+ (wireChannelEvent || channelHandle)) {
7336
+ const streamConnectorId = channelConn.id;
7337
+ const opened = (ref) => {
7338
+ if (sessionChannelBinding?.connectorId !== streamConnectorId)
7339
+ return;
7340
+ sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
7341
+ if (hydrateMessages)
7342
+ return;
7343
+ void saveTranscript({
7344
+ reason: "turn-start",
7345
+ messages: accumulatedUIMessages,
7346
+ turn,
7347
+ trigger: storageTrigger(currentWirePayload.trigger),
7348
+ clientData,
7349
+ lastOutEventId: lastSnapshotOutEventId,
7350
+ skipIfUnchanged: true,
7351
+ carryInCursor: true,
7352
+ }).catch((error) => {
7353
+ logger.warn("chat.agent: saving the open reply stream failed", {
7354
+ error: error instanceof Error ? error.message : String(error),
7355
+ });
7356
+ });
7357
+ };
7358
+ try {
7359
+ channelReplyStream =
7360
+ channelConn.stream({
7361
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7362
+ opened,
7363
+ }) ?? undefined;
7364
+ }
7365
+ catch (streamError) {
7366
+ logger.warn("chat.agent: channel stream() threw; posting the reply", {
7367
+ error: streamError,
7368
+ });
7369
+ }
7370
+ }
7371
+ if (channelReplyStream) {
7372
+ const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
7373
+ observers.push(replyObserver);
7374
+ tapObservers.push(replyObserver);
7375
+ }
7376
+ else if (wireChannelEvent &&
7377
+ channelConn.send &&
7378
+ channelConn.delivery === "stream" &&
7379
+ channelAckRef) {
7380
+ observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
7381
+ }
7382
+ if (channelConn.progress && (wireChannelEvent || channelHandle)) {
7383
+ const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
7384
+ observers.push(progressObserver);
7385
+ tapObservers.push(progressObserver);
7386
+ }
7387
+ if (tapObservers.length > 0) {
7388
+ const tapObserver = composeChannelObservers(tapObservers);
7389
+ locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
7390
+ }
7391
+ if (observers.length > 0) {
7392
+ channelStreamEditor = composeChannelObservers(observers);
7393
+ }
7394
+ }
5687
7395
  runResult = await userRun({
5688
7396
  ...restWire,
5689
7397
  // A turn requested by chat.turn() is not the action itself:
5690
7398
  // a run() that short-circuits on "action" must still answer.
5691
7399
  ...(actionTurn ? { trigger: "action-turn" } : {}),
5692
- messages: preparedMessages,
7400
+ messages: runMessages,
5693
7401
  clientData,
5694
7402
  continuation,
5695
7403
  previousRunId,
@@ -5702,6 +7410,7 @@ function chatAgent(options) {
5702
7410
  cancelSignal,
5703
7411
  stopSignal,
5704
7412
  streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
7413
+ channel: channelSurface,
5705
7414
  });
5706
7415
  }
5707
7416
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5735,7 +7444,13 @@ function chatAgent(options) {
5735
7444
  resolveOnFinish();
5736
7445
  },
5737
7446
  });
5738
- await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
7447
+ // The automatic pipe feeds every observer (progress and the ack stream editor)
7448
+ // itself; the manual tap is cleared so pipeChat does not feed progress twice.
7449
+ locals.set(chatTurnChannelTapKey, undefined);
7450
+ const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
7451
+ ? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
7452
+ : uiStream;
7453
+ await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
5739
7454
  signal: combinedSignal,
5740
7455
  spanName: "stream response",
5741
7456
  }, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
@@ -5758,6 +7473,9 @@ function chatAgent(options) {
5758
7473
  }
5759
7474
  finally {
5760
7475
  msgSub?.off();
7476
+ turnControlSub?.off();
7477
+ locals.set(chatTurnChannelTapKey, undefined);
7478
+ await channelStreamEditor?.stop();
5761
7479
  }
5762
7480
  // Wait for onFinish to fire — on abort this may resolve slightly
5763
7481
  // after pipeChat, since the stream's cancel() handler is async.
@@ -6001,6 +7719,16 @@ function chatAgent(options) {
6001
7719
  if (capturedResponseMessage) {
6002
7720
  responseCommitted = true;
6003
7721
  capturedPartialResponse = capturedResponseMessage;
7722
+ const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
7723
+ channelStreamErrorText = errorChunk?.errorText;
7724
+ channelTurnText = turnBufferedChunks
7725
+ .map((chunk) => {
7726
+ const c = chunk;
7727
+ return c.type === "text-delta" && typeof c.delta === "string"
7728
+ ? c.delta
7729
+ : "";
7730
+ })
7731
+ .join("");
6004
7732
  turnBufferedChunks.length = 0;
6005
7733
  }
6006
7734
  if (runSignal.aborted)
@@ -6220,8 +7948,186 @@ function chatAgent(options) {
6220
7948
  }
6221
7949
  }
6222
7950
  settleRecoveredTurn(currentWirePayload);
7951
+ openChannelEgressLatch(currentWirePayload.chatId);
6223
7952
  // Write turn-complete control chunk — closes the frontend stream.
6224
7953
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
7954
+ // Tool calls that have not had controls posted yet; a resume with the rest still
7955
+ // pending must not repost them or hide the answer text behind them.
7956
+ const pendingToolCalls = (wireChannelEvent || boundTurn) &&
7957
+ channelConn?.send &&
7958
+ turnCompleteEvent.responseMessage
7959
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
7960
+ : [];
7961
+ const renderControls = !!((wireChannelEvent || boundTurn) &&
7962
+ pendingToolCalls.length > 0 &&
7963
+ channelConn?.renderInteraction);
7964
+ let channelReplyDelivered = false;
7965
+ if (channelReplyStream) {
7966
+ const replyStream = channelReplyStream;
7967
+ channelReplyStream = undefined;
7968
+ try {
7969
+ const ended = await replyStream.end({
7970
+ stopped: turnCompleteEvent.stopped,
7971
+ pending: renderControls,
7972
+ ...(channelStreamErrorText ? { errorText: channelStreamErrorText } : {}),
7973
+ });
7974
+ channelReplyDelivered = ended.delivered;
7975
+ }
7976
+ catch (streamError) {
7977
+ logger.warn("chat.agent: channel reply stream end failed", {
7978
+ error: streamError,
7979
+ });
7980
+ }
7981
+ finally {
7982
+ clearOpenReplyStream();
7983
+ }
7984
+ }
7985
+ // Channel egress: map the turn's reply via outbound() and send it, editing the ack
7986
+ // placeholder posted at turn start (previousRef). A failed send is retried; one that
7987
+ // never lands is logged and the turn gets the error reaction. A manual-pipe turn has
7988
+ // no responseMessage, so it opts out.
7989
+ const finalChannelConn = channelConn;
7990
+ if ((wireChannelEvent || boundTurn) &&
7991
+ finalChannelConn?.send &&
7992
+ turnCompleteEvent.responseMessage) {
7993
+ if (renderControls) {
7994
+ for (const call of pendingToolCalls) {
7995
+ channelRenderedToolCallIds.add(call.toolCallId);
7996
+ }
7997
+ }
7998
+ const replyText = channelTurnText && channelTurnText.trim().length > 0
7999
+ ? channelTurnText
8000
+ : channelReplyText(turnCompleteEvent.responseMessage);
8001
+ // A model or stream error surfaces as an `error` chunk and an empty reply, not as a
8002
+ // thrown turn. The mapper gets it on this one call, with the error text standing in
8003
+ // for an empty reply, so the placeholder never sits there as if the agent were
8004
+ // still working.
8005
+ const finalReply = {
8006
+ // Prefer what this turn streamed; a resumed message carries earlier turns' text too.
8007
+ text: channelStreamErrorText && replyText.trim().length === 0
8008
+ ? channelStreamErrorText
8009
+ : replyText,
8010
+ message: turnCompleteEvent.responseMessage,
8011
+ final: true,
8012
+ stopped: turnCompleteEvent.stopped,
8013
+ ...(renderControls ? { pending: true } : {}),
8014
+ ...(channelStreamErrorText
8015
+ ? { error: new Error(channelStreamErrorText) }
8016
+ : {}),
8017
+ };
8018
+ let outboundMessage = null;
8019
+ if (!channelReplyDelivered) {
8020
+ try {
8021
+ outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
8022
+ }
8023
+ catch (outboundError) {
8024
+ logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
8025
+ error: outboundError,
8026
+ });
8027
+ outboundMessage = defaultChannelOutbound(finalReply) ?? {
8028
+ text: "An unexpected error occurred",
8029
+ };
8030
+ }
8031
+ }
8032
+ const sendFinal = async (message) => {
8033
+ for (let attempt = 1;; attempt++) {
8034
+ try {
8035
+ const sent = await finalChannelConn.send(message, {
8036
+ event: wireChannelEvent?.event,
8037
+ handle: channelHandle,
8038
+ deliveryId: wireChannelEvent?.deliveryId,
8039
+ previousRef: channelAckRef,
8040
+ mode: finalChannelConn.delivery,
8041
+ final: true,
8042
+ bound: boundTurn,
8043
+ });
8044
+ channelFinalAnswerPosted = true;
8045
+ return sent ?? {};
8046
+ }
8047
+ catch (egressError) {
8048
+ if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
8049
+ channelFinalSendFailed = true;
8050
+ logger.error("chat.agent: channel egress send failed", {
8051
+ error: egressError,
8052
+ attempts: attempt,
8053
+ });
8054
+ return undefined;
8055
+ }
8056
+ logger.warn("chat.agent: channel egress send failed; retrying", {
8057
+ error: egressError,
8058
+ attempt,
8059
+ });
8060
+ await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
8061
+ }
8062
+ }
8063
+ };
8064
+ // A turn that pauses on a human decision often explains itself first (a summary,
8065
+ // the steps so far) and then calls the tool. That text is the answer so far: post it
8066
+ // as the placeholder edit, and post the controls as their own message after it.
8067
+ if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
8068
+ channelAckRef = undefined;
8069
+ }
8070
+ const channelMessage = renderControls
8071
+ ? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
8072
+ ? {
8073
+ event: wireChannelEvent.event,
8074
+ deliveryId: wireChannelEvent.deliveryId,
8075
+ }
8076
+ : {})
8077
+ : outboundMessage;
8078
+ if (channelMessage) {
8079
+ const controlsRef = (await sendFinal(channelMessage))?.ref;
8080
+ if (renderControls &&
8081
+ controlsRef &&
8082
+ sessionChannelBinding?.connectorId === finalChannelConn.id) {
8083
+ sessionChannelBinding = {
8084
+ ...sessionChannelBinding,
8085
+ controls: {
8086
+ ...sessionChannelBinding.controls,
8087
+ ...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
8088
+ },
8089
+ };
8090
+ }
8091
+ }
8092
+ }
8093
+ if (channelTurnRan)
8094
+ lastChannelTurnEndedAt = Date.now();
8095
+ clearChannelWorking();
8096
+ if (channelLifecycleStarted) {
8097
+ const stillPending = turnCompleteEvent.responseMessage
8098
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
8099
+ : false;
8100
+ await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey)
8101
+ ? "closed"
8102
+ : stillPending
8103
+ ? "waiting"
8104
+ : "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
8105
+ }
8106
+ // Lifecycle reaction: turn done. Remove "working", mark "done".
8107
+ if (wireChannelEvent && channelConn?.react) {
8108
+ try {
8109
+ if (channelWorkingReaction) {
8110
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8111
+ name: channelWorkingReaction,
8112
+ remove: true,
8113
+ });
8114
+ }
8115
+ const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
8116
+ ? channelConn.reactions?.error
8117
+ : channelConn.reactions?.done, wireChannelEvent.event);
8118
+ if (outcomeReaction) {
8119
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8120
+ name: outcomeReaction,
8121
+ });
8122
+ }
8123
+ }
8124
+ catch (reactionError) {
8125
+ logger.warn("chat.agent: channel done reaction failed", {
8126
+ error: reactionError,
8127
+ });
8128
+ }
8129
+ }
8130
+ settleChannelEgressLatch(currentWirePayload.chatId);
6225
8131
  // Fire onTurnComplete — stream is closed, use for persistence.
6226
8132
  if (onTurnComplete) {
6227
8133
  await tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -6339,6 +8245,7 @@ function chatAgent(options) {
6339
8245
  // chat.endRun() — same exit, no upgrade semantics.
6340
8246
  if (locals.get(chatCloseRequestedKey)) {
6341
8247
  await performChatClose();
8248
+ await reportChannelClosed();
6342
8249
  return "exit";
6343
8250
  }
6344
8251
  if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
@@ -6410,6 +8317,7 @@ function chatAgent(options) {
6410
8317
  currentWirePayload = next.output;
6411
8318
  // Close signal — exit the loop gracefully
6412
8319
  if (currentWirePayload.trigger === "close") {
8320
+ await reportChannelClosed();
6413
8321
  return "exit";
6414
8322
  }
6415
8323
  return "continue";
@@ -6427,12 +8335,16 @@ function chatAgent(options) {
6427
8335
  // Detach the turn's message handler first — left attached it would
6428
8336
  // eat the very message the wait below is waiting for.
6429
8337
  turnMsgSub?.off();
8338
+ turnControlSub?.off();
6430
8339
  if (turnError instanceof Error &&
6431
8340
  turnError.name === "AbortError" &&
6432
8341
  runSignal.aborted) {
8342
+ settleChannelEgressLatch(currentWirePayload.chatId);
8343
+ channelLifecycle.dispose();
6433
8344
  // Full run cancellation — exit immediately
6434
8345
  throw turnError;
6435
8346
  }
8347
+ settleChannelEgressLatch(currentWirePayload.chatId);
6436
8348
  // OOM errors must escape the turn loop so the task runtime can
6437
8349
  // honor `retry.outOfMemory.machine` (set on chat.agent via
6438
8350
  // `oomMachine`). Catching them here would keep the dead worker
@@ -6443,6 +8355,109 @@ function chatAgent(options) {
6443
8355
  if (turnError instanceof OutOfMemoryError) {
6444
8356
  throw turnError;
6445
8357
  }
8358
+ if (channelWireEvent && channelConn?.react) {
8359
+ try {
8360
+ if (channelWorkingReaction) {
8361
+ await applyChannelReaction(channelConn, channelWireEvent, {
8362
+ name: channelWorkingReaction,
8363
+ remove: true,
8364
+ });
8365
+ }
8366
+ const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
8367
+ if (errorReaction) {
8368
+ await applyChannelReaction(channelConn, channelWireEvent, {
8369
+ name: errorReaction,
8370
+ });
8371
+ }
8372
+ }
8373
+ catch (reactionError) {
8374
+ logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
8375
+ }
8376
+ }
8377
+ let channelErrorText = "An unexpected error occurred";
8378
+ if ((channelWireEvent || boundTurn) && channelConn) {
8379
+ const sanitizeChannelError = resolveUIMessageStreamOptions().onError;
8380
+ try {
8381
+ channelErrorText = sanitizeChannelError
8382
+ ? sanitizeChannelError(turnError)
8383
+ : turnError instanceof Error
8384
+ ? turnError.message
8385
+ : channelErrorText;
8386
+ }
8387
+ catch { }
8388
+ }
8389
+ let errorStreamDelivered = false;
8390
+ if (channelReplyStream) {
8391
+ const replyStream = channelReplyStream;
8392
+ channelReplyStream = undefined;
8393
+ try {
8394
+ const ended = await replyStream.end({
8395
+ stopped: false,
8396
+ pending: false,
8397
+ errorText: channelErrorText,
8398
+ });
8399
+ errorStreamDelivered = ended.delivered;
8400
+ }
8401
+ catch (streamError) {
8402
+ logger.warn("chat.agent: channel reply stream end failed", {
8403
+ error: streamError,
8404
+ });
8405
+ }
8406
+ finally {
8407
+ clearOpenReplyStream();
8408
+ }
8409
+ }
8410
+ if ((channelWireEvent || boundTurn) && channelConn?.send) {
8411
+ const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
8412
+ const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
8413
+ let errorMessage = null;
8414
+ try {
8415
+ errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
8416
+ text: channelErrorText,
8417
+ message: {
8418
+ id: channelErrorMessageId,
8419
+ role: "assistant",
8420
+ parts: [{ type: "text", text: channelErrorText }],
8421
+ },
8422
+ final: true,
8423
+ stopped: false,
8424
+ error: turnError,
8425
+ });
8426
+ }
8427
+ catch (outboundError) {
8428
+ logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
8429
+ error: outboundError,
8430
+ });
8431
+ errorMessage = { text: channelErrorText };
8432
+ }
8433
+ if (errorMessage && !errorStreamDelivered) {
8434
+ try {
8435
+ await channelConn.send(errorMessage, {
8436
+ event: channelWireEvent?.event,
8437
+ handle: channelHandle,
8438
+ deliveryId: channelWireEvent?.deliveryId,
8439
+ previousRef: errorPreviousRef,
8440
+ mode: channelConn.delivery,
8441
+ final: true,
8442
+ bound: boundTurn,
8443
+ });
8444
+ }
8445
+ catch (egressError) {
8446
+ logger.warn("chat.agent: channel error egress send failed", {
8447
+ error: egressError,
8448
+ });
8449
+ }
8450
+ }
8451
+ }
8452
+ if (channelTurnRan)
8453
+ lastChannelTurnEndedAt = Date.now();
8454
+ clearChannelWorking();
8455
+ if (channelLifecycleStarted) {
8456
+ await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
8457
+ ...channelTurnCtx(channelWireEvent, channelHandle),
8458
+ firstTurn: false,
8459
+ });
8460
+ }
6446
8461
  let errorTurnCompleteResult;
6447
8462
  try {
6448
8463
  await withChatWriter(async (writer) => {
@@ -6699,6 +8714,7 @@ function chatAgent(options) {
6699
8714
  .get(chatManagedResponseKey)
6700
8715
  ?.close()
6701
8716
  .catch(() => { });
8717
+ turnControlSub?.off();
6702
8718
  }
6703
8719
  }
6704
8720
  }
@@ -6722,6 +8738,45 @@ function chatAgent(options) {
6722
8738
  schema: clientDataSchema,
6723
8739
  });
6724
8740
  }
8741
+ // Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
8742
+ // The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
8743
+ if (events) {
8744
+ for (const wh of events) {
8745
+ resourceCatalog.markSessionWebhookClaimed(wh.id);
8746
+ resourceCatalog.registerWebhookSubscriber({
8747
+ endpointId: wh.endpointId,
8748
+ target: {
8749
+ type: "session",
8750
+ id: `${options.id}:${wh.id}`,
8751
+ taskIdentifier: options.id,
8752
+ keyTemplate: wh.key,
8753
+ actionType: wh.type,
8754
+ deliverAs: "action",
8755
+ ...(wh.filter ? { filter: wh.filter } : {}),
8756
+ },
8757
+ });
8758
+ }
8759
+ }
8760
+ // Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
8761
+ // verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
8762
+ if (channels) {
8763
+ for (const ch of channels) {
8764
+ resourceCatalog.markSessionWebhookClaimed(ch.id);
8765
+ resourceCatalog.registerWebhookSubscriber({
8766
+ endpointId: ch.endpointId,
8767
+ target: {
8768
+ type: "session",
8769
+ id: `${options.id}:${ch.id}`,
8770
+ taskIdentifier: options.id,
8771
+ keyTemplate: ch.key,
8772
+ connectorId: ch.id,
8773
+ deliverAs: "message",
8774
+ ...(ch.filter ? { filter: ch.filter } : {}),
8775
+ ...(ch.startOn ? { startOn: ch.startOn } : {}),
8776
+ },
8777
+ });
8778
+ }
8779
+ }
6725
8780
  return task;
6726
8781
  }
6727
8782
  function composeHooks(builderHook, taskHook) {
@@ -7017,6 +9072,22 @@ function resolveUIMessageStreamOptions() {
7017
9072
  // ---------------------------------------------------------------------------
7018
9073
  // Stop detection
7019
9074
  // ---------------------------------------------------------------------------
9075
+ /**
9076
+ * The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
9077
+ * tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
9078
+ * `surface()`. Pass the connector's surface type to get its helpers typed:
9079
+ *
9080
+ * @example
9081
+ * ```ts
9082
+ * execute: async ({ steps }) => {
9083
+ * const linear = chat.surface<LinearRunSurface>();
9084
+ * if (linear) await linear.plan(steps);
9085
+ * }
9086
+ * ```
9087
+ */
9088
+ function getChannelSurface() {
9089
+ return locals.get(chatChannelSurfaceKey);
9090
+ }
7020
9091
  /**
7021
9092
  * Check whether the user stopped generation during the current turn.
7022
9093
  *
@@ -9014,6 +11085,10 @@ function createChatLoadTranscriptAction(storage, options) {
9014
11085
  export const chat = {
9015
11086
  /** Create a chat agent. See {@link chatAgent}. */
9016
11087
  agent: chatAgent,
11088
+ /** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
11089
+ event: chatEvent,
11090
+ /** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
11091
+ channels: chatChannels,
9017
11092
  /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
9018
11093
  customAgent: chatCustomAgent,
9019
11094
  /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
@@ -9032,6 +11107,8 @@ export const chat = {
9032
11107
  local: chatLocal,
9033
11108
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
9034
11109
  createAccessToken: createChatAccessToken,
11110
+ /** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
11111
+ createWatchToken: createChatWatchToken,
9035
11112
  /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
9036
11113
  setTurnTimeout,
9037
11114
  /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
@@ -9042,6 +11119,8 @@ export const chat = {
9042
11119
  setUIMessageStreamOptions,
9043
11120
  /** Check if the current turn was stopped by the user. See {@link isStopped}. */
9044
11121
  isStopped,
11122
+ /** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
11123
+ surface: getChannelSurface,
9045
11124
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
9046
11125
  requestUpgrade,
9047
11126
  /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */