@trigger.dev/sdk 4.7.4 → 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 (77) 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/index.d.ts +2 -0
  7. package/dist/commonjs/v3/index.js +23 -17
  8. package/dist/commonjs/v3/index.js.map +1 -1
  9. package/dist/commonjs/v3/modelFileDownload.d.ts +10 -0
  10. package/dist/commonjs/v3/modelFileDownload.js +164 -0
  11. package/dist/commonjs/v3/modelFileDownload.js.map +1 -0
  12. package/dist/commonjs/v3/test/index.d.ts +2 -1
  13. package/dist/commonjs/v3/test/index.js +4 -1
  14. package/dist/commonjs/v3/test/index.js.map +1 -1
  15. package/dist/commonjs/v3/test/mock-channel-connector.d.ts +172 -0
  16. package/dist/commonjs/v3/test/mock-channel-connector.js +173 -0
  17. package/dist/commonjs/v3/test/mock-channel-connector.js.map +1 -0
  18. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +81 -1
  19. package/dist/commonjs/v3/test/mock-chat-agent.js +137 -10
  20. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  21. package/dist/commonjs/v3/transcriptStorage.d.ts +44 -1
  22. package/dist/commonjs/v3/transcriptStorage.js +24 -0
  23. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  24. package/dist/commonjs/v3/webhookWaiters.d.ts +80 -0
  25. package/dist/commonjs/v3/webhookWaiters.js +156 -0
  26. package/dist/commonjs/v3/webhookWaiters.js.map +1 -0
  27. package/dist/commonjs/v3/webhooks.d.ts +178 -2
  28. package/dist/commonjs/v3/webhooks.js +322 -1
  29. package/dist/commonjs/v3/webhooks.js.map +1 -1
  30. package/dist/commonjs/version.js +1 -1
  31. package/dist/esm/v3/ai.d.ts +560 -8
  32. package/dist/esm/v3/ai.js +2257 -178
  33. package/dist/esm/v3/ai.js.map +1 -1
  34. package/dist/esm/v3/chat.d.ts +23 -0
  35. package/dist/esm/v3/chat.js.map +1 -1
  36. package/dist/esm/v3/index.d.ts +2 -0
  37. package/dist/esm/v3/index.js +1 -0
  38. package/dist/esm/v3/index.js.map +1 -1
  39. package/dist/esm/v3/modelFileDownload.d.ts +10 -0
  40. package/dist/esm/v3/modelFileDownload.js +161 -0
  41. package/dist/esm/v3/modelFileDownload.js.map +1 -0
  42. package/dist/esm/v3/test/index.d.ts +2 -1
  43. package/dist/esm/v3/test/index.js +2 -1
  44. package/dist/esm/v3/test/index.js.map +1 -1
  45. package/dist/esm/v3/test/mock-channel-connector.d.ts +172 -0
  46. package/dist/esm/v3/test/mock-channel-connector.js +170 -0
  47. package/dist/esm/v3/test/mock-channel-connector.js.map +1 -0
  48. package/dist/esm/v3/test/mock-chat-agent.d.ts +81 -1
  49. package/dist/esm/v3/test/mock-chat-agent.js +137 -11
  50. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  51. package/dist/esm/v3/transcriptStorage.d.ts +44 -1
  52. package/dist/esm/v3/transcriptStorage.js +24 -0
  53. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  54. package/dist/esm/v3/webhookWaiters.d.ts +80 -0
  55. package/dist/esm/v3/webhookWaiters.js +149 -0
  56. package/dist/esm/v3/webhookWaiters.js.map +1 -0
  57. package/dist/esm/v3/webhooks.d.ts +178 -2
  58. package/dist/esm/v3/webhooks.js +318 -1
  59. package/dist/esm/v3/webhooks.js.map +1 -1
  60. package/dist/esm/version.js +1 -1
  61. package/docs/ai-chat/actions.mdx +6 -1
  62. package/docs/ai-chat/backend.mdx +34 -0
  63. package/docs/ai-chat/pending-messages.mdx +2 -0
  64. package/docs/ai-chat/reference.mdx +50 -2
  65. package/docs/mcp-tools.mdx +76 -0
  66. package/docs/webhooks/channels.mdx +128 -0
  67. package/docs/webhooks/connect.mdx +80 -0
  68. package/docs/webhooks/deliveries.mdx +92 -0
  69. package/docs/webhooks/filters.mdx +109 -0
  70. package/docs/webhooks/human-in-the-loop.mdx +149 -0
  71. package/docs/webhooks/linear.mdx +185 -0
  72. package/docs/webhooks/overview.mdx +99 -0
  73. package/docs/webhooks/session-routing.mdx +132 -0
  74. package/docs/webhooks/slack.mdx +360 -0
  75. package/docs/webhooks/sources.mdx +217 -0
  76. package/docs/webhooks/waiters.mdx +170 -0
  77. package/package.json +3 -2
@@ -11,7 +11,16 @@ exports.__resetChatInputRouterForTests = __resetChatInputRouterForTests;
11
11
  exports.__chatInputCheckpointForTests = __chatInputCheckpointForTests;
12
12
  exports.__findLatestSessionInCheckpointForTests = __findLatestSessionInCheckpointForTests;
13
13
  exports.buildSkillTools = buildSkillTools;
14
+ exports.chatChannelCustom = chatChannelCustom;
15
+ exports.resolveReactionChoice = resolveReactionChoice;
16
+ exports.__makeChannelStreamEditorForTests = makeChannelStreamEditor;
17
+ exports.__makeChannelStreamTapForTests = makeChannelStreamTap;
18
+ exports.__makeChannelProgressObserverForTests = makeChannelProgressObserver;
19
+ exports.__makeChannelLifecycleReporterForTests = makeChannelLifecycleReporter;
20
+ exports.__waitForChannelEgressForTests = __waitForChannelEgressForTests;
14
21
  const v3_1 = require("@trigger.dev/core/v3");
22
+ const webhooks_js_1 = require("./webhooks.js");
23
+ const modelFileDownload_js_1 = require("./modelFileDownload.js");
15
24
  // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
16
25
  // ESM-only `ai@7` (see ../imports/ai-runtime.ts).
17
26
  const api_1 = require("@opentelemetry/api");
@@ -88,6 +97,14 @@ const chatSessionHandleKey = locals_js_1.locals.create("chat.sessionHandle");
88
97
  // is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
89
98
  // metadata reads this directly rather than the Session handle id.
90
99
  const chatExternalIdKey = locals_js_1.locals.create("chat.externalId");
100
+ /**
101
+ * Set at boot when a continuation run inherited an interrupted turn (a
102
+ * partial assistant reply on `session.out`). Consumed by the first channel
103
+ * turn's ack so the re-emitted placeholder reads as a recovery resume rather
104
+ * than a silent duplicate. Boxed so the ack site can flip it to false.
105
+ * @internal
106
+ */
107
+ const chatChannelRecoveryPendingKey = locals_js_1.locals.create("chat.channelRecoveryPending");
91
108
  /**
92
109
  * S2 seq_num of the most recent `turn-complete` control record written by
93
110
  * this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -379,14 +396,23 @@ function __setReplaySessionInTailImplForTests(impl) {
379
396
  * @internal
380
397
  */
381
398
  async function replaySessionInTail(sessionId, options) {
399
+ return (await replaySessionInTailWithChannels(sessionId, options)).messages;
400
+ }
401
+ /**
402
+ * The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
403
+ * seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
404
+ */
405
+ async function replaySessionInTailWithChannels(sessionId, options) {
382
406
  if (replaySessionInTailImpl) {
383
- return await replaySessionInTailImpl(sessionId, options);
407
+ const replayed = await replaySessionInTailImpl(sessionId, options);
408
+ return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
384
409
  }
385
410
  const apiClient = v3_1.apiClientManager.clientOrThrow();
386
411
  const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
387
412
  afterEventId: options?.lastEventId,
388
413
  });
389
414
  const out = [];
415
+ const channelEvents = [];
390
416
  for (const record of response.records) {
391
417
  // session.in writers POST `JSON.stringify(chunk)` directly; the
392
418
  // webapp wraps that in `{ data: <string>, id }` and stores it on
@@ -413,15 +439,23 @@ async function replaySessionInTail(sessionId, options) {
413
439
  if (!payload || payload.trigger !== "submit-message")
414
440
  continue;
415
441
  const message = payload.message;
416
- if (!message || typeof message !== "object")
442
+ if (!message || typeof message !== "object") {
443
+ if (payload.channelEvent && typeof payload.channelEvent === "object") {
444
+ channelEvents.push({
445
+ channelEvent: payload.channelEvent,
446
+ metadata: payload.metadata,
447
+ seqNum: record.seqNum,
448
+ });
449
+ }
417
450
  continue;
451
+ }
418
452
  out.push({
419
453
  message: message,
420
454
  metadata: payload.metadata,
421
455
  seqNum: record.seqNum,
422
456
  });
423
457
  }
424
- return out;
458
+ return { messages: out, channelEvents };
425
459
  }
426
460
  /**
427
461
  * Test-only entry point that bypasses
@@ -682,6 +716,37 @@ exports.ai = {
682
716
  function createChatAccessToken(taskId) {
683
717
  return auth_js_1.auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
684
718
  }
719
+ /**
720
+ * Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
721
+ *
722
+ * A session is addressed by `externalId`, so the same session is reachable from any surface that
723
+ * knows it: a web `useChat` client and a Slack thread converge on one session when they share an
724
+ * `externalId` (for a channel, the connector's `key` template produces it). This token carries
725
+ * `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
726
+ * stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
727
+ *
728
+ * Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
729
+ * (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
730
+ * a read+write token instead ({@link createChatStartSessionAction}).
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * // actions.ts
735
+ * "use server";
736
+ * import { chat } from "@trigger.dev/sdk/ai";
737
+ *
738
+ * export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
739
+ * ```
740
+ */
741
+ function createChatWatchToken(externalId, options) {
742
+ if (!externalId) {
743
+ throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
744
+ }
745
+ return auth_js_1.auth.createPublicToken({
746
+ scopes: { read: { sessions: externalId } },
747
+ expirationTime: options?.tokenTTL ?? "1h",
748
+ });
749
+ }
685
750
  function createChatOutGate() {
686
751
  let resolveOpened;
687
752
  const opened = new Promise((resolve) => {
@@ -1918,7 +1983,16 @@ const chatInstructionsConsumedKey = locals_js_1.locals.create("chat.injectedInst
1918
1983
  * @internal
1919
1984
  */
1920
1985
  const chatPipeCountKey = locals_js_1.locals.create("chat.pipeCount");
1986
+ /**
1987
+ * The current turn's channel observer tap, set before run() when the turn has channel observers
1988
+ * (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
1989
+ * manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
1990
+ * @internal
1991
+ */
1992
+ const chatTurnChannelTapKey = locals_js_1.locals.create("chat.turnChannelTap");
1921
1993
  const chatStopControllerKey = locals_js_1.locals.create("chat.stopController");
1994
+ const chatChannelSurfaceKey = locals_js_1.locals.create("chat.channelSurface");
1995
+ const chatChannelDownloadKey = locals_js_1.locals.create("chat.channelDownload");
1922
1996
  /** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
1923
1997
  const chatUIStreamStaticKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.static");
1924
1998
  /** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
@@ -3284,7 +3358,7 @@ function buildManagedStreamTextOptions(options, config) {
3284
3358
  * caller's value winning because `rest` is spread after `managed`. Pulling a
3285
3359
  * key out to "handle" it is how a caller's option gets silently dropped.
3286
3360
  */
3287
- const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
3361
+ const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
3288
3362
  const managed = toStreamTextOptions({
3289
3363
  registry,
3290
3364
  system: callerSystem ?? agentSystem,
@@ -3327,6 +3401,12 @@ function buildManagedStreamTextOptions(options, config) {
3327
3401
  if (typeof managed.prepareStep === "function") {
3328
3402
  managed.prepareStep = (0, steeringContext_js_1.retainStepMessages)(managed.prepareStep);
3329
3403
  }
3404
+ if (typeof callerDownload === "function") {
3405
+ const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
3406
+ managed.experimental_download = channelDownload
3407
+ ? channelAwareDownload(channelDownload, callerDownload)
3408
+ : callerDownload;
3409
+ }
3330
3410
  return { ...managed, ...rest };
3331
3411
  }
3332
3412
  /** @internal Test hook for {@link buildManagedStreamTextOptions}. */
@@ -3556,6 +3636,10 @@ function toStreamTextOptions(options, retain = true) {
3556
3636
  if (retain && typeof result.prepareStep === "function") {
3557
3637
  result.prepareStep = (0, steeringContext_js_1.retainStepMessages)(result.prepareStep);
3558
3638
  }
3639
+ const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
3640
+ if (channelDownload) {
3641
+ result.experimental_download = channelAwareDownload(channelDownload);
3642
+ }
3559
3643
  return result;
3560
3644
  }
3561
3645
  const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
@@ -3570,6 +3654,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3570
3654
  * tools, steering, compaction, injected instructions and persistence, with no
3571
3655
  * action-specific handling.
3572
3656
  *
3657
+ * Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
3658
+ * "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
3659
+ *
3573
3660
  * @example
3574
3661
  * ```ts
3575
3662
  * onAction: async ({ action }) => {
@@ -3577,12 +3664,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
3577
3664
  * chat.history.slice(0, -1);
3578
3665
  * return chat.turn();
3579
3666
  * }
3667
+ * if (action.type === "ask") return chat.turn(action.question);
3580
3668
  * if (action.type === "undo") chat.history.slice(0, -2); // no turn
3581
3669
  * },
3582
3670
  * ```
3583
3671
  */
3584
- function chatTurn() {
3585
- return { [actionTurnBrand]: true };
3672
+ function chatTurn(message) {
3673
+ return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
3586
3674
  }
3587
3675
  function isActionTurn(value) {
3588
3676
  return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
@@ -3648,6 +3736,21 @@ function warnMissingOnActionOnce() {
3648
3736
  function isAsyncIterable(value) {
3649
3737
  return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
3650
3738
  }
3739
+ function readableStreamFromAsyncIterable(source) {
3740
+ const iterator = source[Symbol.asyncIterator]();
3741
+ return new ReadableStream({
3742
+ async pull(controller) {
3743
+ const next = await iterator.next();
3744
+ if (next.done)
3745
+ controller.close();
3746
+ else
3747
+ controller.enqueue(next.value);
3748
+ },
3749
+ async cancel(reason) {
3750
+ await iterator.return?.(reason);
3751
+ },
3752
+ });
3753
+ }
3651
3754
  function isReadableStream(value) {
3652
3755
  return (typeof value === "object" && value !== null && typeof value.getReader === "function");
3653
3756
  }
@@ -3703,6 +3806,10 @@ async function pipeChat(source, options, capture) {
3703
3806
  throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
3704
3807
  "an AsyncIterable, or a ReadableStream");
3705
3808
  }
3809
+ const channelTap = locals_js_1.locals.get(chatTurnChannelTapKey);
3810
+ if (channelTap) {
3811
+ stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
3812
+ }
3706
3813
  const pipeOptions = {};
3707
3814
  if (options?.signal) {
3708
3815
  pipeOptions.signal = options.signal;
@@ -3732,160 +3839,1026 @@ async function pipeChat(source, options, capture) {
3732
3839
  await waitUntilComplete();
3733
3840
  }
3734
3841
  }
3735
- function chatCustomAgent(options) {
3736
- const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
3737
- const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3738
- const parseClientDataSync = clientDataSchema
3739
- ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
3740
- : undefined;
3741
- const task = (0, shared_js_1.createTask)({
3742
- ...restOptions,
3743
- triggerSource: "agent",
3744
- agentConfig: { type: "ai-sdk-chat" },
3745
- run: async (payload, runOptions) => {
3746
- // Bind the run to its backing Session so module-level helpers
3747
- // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
3748
- // resolve to this chat's `.in` / `.out` channels. Address
3749
- // everywhere by `payload.chatId` (the session externalId) so the
3750
- // agent's writes and the transport's reads converge on the same
3751
- // S2 stream key + waitpoint key.
3752
- //
3753
- // The Session row is created server-side by `POST /sessions` (or
3754
- // `chat.createStartSessionAction`) before this run is triggered.
3755
- // No client-side upsert needed.
3756
- locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
3757
- locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
3758
- locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
3759
- locals_js_1.locals.set(chatCustomAgentRunKey, true);
3760
- if (parseClientData && parseClientDataSync) {
3761
- locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
3762
- parse: parseClientData,
3763
- parseSync: parseClientDataSync,
3764
- });
3842
+ /**
3843
+ * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
3844
+ * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
3845
+ * control. Use for providers without a preset, or to test the channel round-trip end to end.
3846
+ */
3847
+ function chatChannelCustom(options) {
3848
+ 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;
3849
+ if (!endpoint?.id) {
3850
+ throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
3851
+ }
3852
+ v3_1.resourceCatalog.registerDeclaredSessionWebhook(id);
3853
+ return {
3854
+ id,
3855
+ endpointId: endpoint.id,
3856
+ source: endpoint.source,
3857
+ key: (0, webhooks_js_1.normalizeKeyString)(key),
3858
+ filter,
3859
+ startOn,
3860
+ inbound,
3861
+ bind,
3862
+ seed,
3863
+ outbound,
3864
+ ack,
3865
+ send,
3866
+ renderInteraction,
3867
+ onInteraction,
3868
+ finalizeInteraction,
3869
+ react,
3870
+ reactions,
3871
+ progress,
3872
+ surface,
3873
+ onControl,
3874
+ stream,
3875
+ endInterruptedStream,
3876
+ download,
3877
+ received,
3878
+ toAction,
3879
+ lifecycle,
3880
+ cancelInteraction,
3881
+ delivery: delivery ?? "final",
3882
+ };
3883
+ }
3884
+ const chatChannels = {
3885
+ /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
3886
+ custom: chatChannelCustom,
3887
+ };
3888
+ // Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
3889
+ function channelSeedMessages(seed, provenance) {
3890
+ return seed
3891
+ .filter((m) => m && typeof m.text === "string" && m.text.length > 0)
3892
+ .map((m, index) => {
3893
+ const meta = {
3894
+ ...provenance,
3895
+ seeded: true,
3896
+ ...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
3897
+ ...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
3898
+ };
3899
+ return {
3900
+ id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
3901
+ role: m.role === "assistant" ? "assistant" : "user",
3902
+ parts: [{ type: "text", text: m.text }],
3903
+ metadata: meta,
3904
+ };
3905
+ });
3906
+ }
3907
+ // Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
3908
+ // it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
3909
+ /**
3910
+ * True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
3911
+ * data parts. A button click from another app on a shared endpoint maps to one.
3912
+ */
3913
+ function isEmptyChannelInbound(input) {
3914
+ const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
3915
+ ? input.message
3916
+ : input;
3917
+ if (typeof message === "string")
3918
+ return message.trim() === "";
3919
+ return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
3920
+ }
3921
+ /** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
3922
+ const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
3923
+ /**
3924
+ * One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
3925
+ * the session first, the next one after a failure) and caches what they return by URL, so a file in
3926
+ * the history is fetched once per run rather than per step.
3927
+ */
3928
+ function makeChannelDownloader(connectors, handleFor) {
3929
+ const downloaders = connectors.filter((c) => typeof c.download === "function");
3930
+ if (downloaders.length === 0)
3931
+ return undefined;
3932
+ const cache = new Map();
3933
+ const sizes = new Map();
3934
+ let cachedBytes = 0;
3935
+ const evict = () => {
3936
+ for (const key of cache.keys()) {
3937
+ if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
3938
+ return;
3939
+ cachedBytes -= sizes.get(key) ?? 0;
3940
+ sizes.delete(key);
3941
+ cache.delete(key);
3942
+ }
3943
+ };
3944
+ const fetchOwned = async (url) => {
3945
+ const candidates = downloaders.map((connector) => ({
3946
+ connector,
3947
+ handle: handleFor(connector.id),
3948
+ }));
3949
+ const ordered = [
3950
+ ...candidates.filter((c) => c.handle !== undefined),
3951
+ ...candidates.filter((c) => c.handle === undefined),
3952
+ ];
3953
+ let failure;
3954
+ for (const { connector, handle } of ordered) {
3955
+ try {
3956
+ const file = await connector.download(url, { handle });
3957
+ if (file)
3958
+ return file;
3765
3959
  }
3766
- if (onClientDataValidationError) {
3767
- locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
3960
+ catch (error) {
3961
+ failure ??= { error };
3768
3962
  }
3769
- locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
3770
- // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
3771
- // trims `session.out` back to the previous turn boundary. Without
3772
- // this the slot is undefined and the trim never runs, so `.out`
3773
- // grows without bound for the whole custom-agent surface.
3774
- locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
3775
- (0, streams_js_1.markChatAgentRunForStreamsWarning)();
3776
- v3_1.taskContext.setConversationId(payload.chatId);
3777
- stampConversationIdOnActiveSpan(payload.chatId);
3778
- await installChatInputRouter(payload.chatId, {
3779
- resuming: Boolean(payload.continuation),
3780
- });
3781
- // A custom agent's loop is the customer's, so there is no exit site the
3782
- // SDK controls. Perform a requested close when `run()` returns, whatever
3783
- // shape the loop had. Idempotent, so the createSession iterator having
3784
- // already closed on its own exit costs nothing.
3785
- const withClose = async (result) => {
3786
- try {
3787
- return await result;
3788
- }
3789
- finally {
3790
- await performChatClose();
3791
- }
3792
- };
3793
- // Keep the schema-free path identical to the original custom-agent
3794
- // wrapper, including when userRun starts executing.
3795
- if (!parseClientData) {
3796
- return withClose(userRun(payload, runOptions));
3963
+ }
3964
+ if (failure)
3965
+ throw failure.error;
3966
+ return null;
3967
+ };
3968
+ return (url) => {
3969
+ const key = url.href;
3970
+ const cached = cache.get(key);
3971
+ if (cached)
3972
+ return cached;
3973
+ const pending = fetchOwned(url).then((file) => {
3974
+ if (file && cache.get(key) === pending) {
3975
+ sizes.set(key, file.data.byteLength);
3976
+ cachedBytes += file.data.byteLength;
3977
+ evict();
3797
3978
  }
3798
- const isHandoverBoot = payload.trigger === "handover-prepare";
3799
- const isMessagelessBoot = payload.trigger === "preload" ||
3800
- (payload.continuation === true &&
3801
- payload.message === undefined &&
3802
- payload.trigger !== "action" &&
3803
- payload.trigger !== "regenerate-message" &&
3804
- !isHandoverBoot);
3805
- const validated = await validateChatCustomAgentPayload(payload, {
3806
- // Preload and continuation boots do not represent a submitted turn,
3807
- // so there is no sender waiting for a terminal frame. Handover errors
3808
- // must be written after the warm response flushes and signals below.
3809
- writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
3979
+ return file;
3980
+ }, (error) => {
3981
+ if (cache.get(key) === pending)
3982
+ cache.delete(key);
3983
+ throw error;
3984
+ });
3985
+ cache.set(key, pending);
3986
+ return pending;
3987
+ };
3988
+ }
3989
+ function fileUrlOf(part) {
3990
+ const p = part;
3991
+ const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
3992
+ if (raw instanceof URL)
3993
+ return raw;
3994
+ if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
3995
+ return undefined;
3996
+ try {
3997
+ return new URL(raw);
3998
+ }
3999
+ catch {
4000
+ return undefined;
4001
+ }
4002
+ }
4003
+ /**
4004
+ * Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
4005
+ * them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
4006
+ * copy of the messages, and the turn still runs. The transcript keeps the URL.
4007
+ */
4008
+ async function resolveChannelFiles(messages, download) {
4009
+ const failed = new Set();
4010
+ const mediaTypes = new Map();
4011
+ const urls = new Map();
4012
+ for (const message of messages) {
4013
+ if (!Array.isArray(message.content))
4014
+ continue;
4015
+ for (const part of message.content) {
4016
+ const url = fileUrlOf(part);
4017
+ if (url)
4018
+ urls.set(url.href, url);
4019
+ }
4020
+ }
4021
+ await Promise.all([...urls.values()].map(async (url) => {
4022
+ try {
4023
+ const file = await download(url);
4024
+ if (file?.mediaType)
4025
+ mediaTypes.set(url.href, file.mediaType);
4026
+ }
4027
+ catch (error) {
4028
+ failed.add(url.href);
4029
+ v3_1.logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
4030
+ url: url.origin + url.pathname,
4031
+ error,
3810
4032
  });
3811
- if (validated.ok) {
3812
- return withClose(userRun(validated.payload, runOptions));
3813
- }
3814
- if (isHandoverBoot) {
3815
- const signal = await waitForHandover({
3816
- payload,
3817
- timeout: "1h",
3818
- spanName: "waiting for handover signal (invalid clientData)",
3819
- });
3820
- if (!signal || signal.kind === "handover-skip") {
3821
- return;
3822
- }
3823
- // The head-start writer flushes before sending this signal. Writing
3824
- // the terminal error now preserves stream order and closes the stitch.
3825
- await writeChatCustomAgentClientDataErrorToStream(payload);
3826
- return;
4033
+ }
4034
+ }));
4035
+ const isWildcard = (part) => {
4036
+ const mediaType = part.mediaType;
4037
+ return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
4038
+ };
4039
+ return messages.map((message) => {
4040
+ if (!Array.isArray(message.content))
4041
+ return message;
4042
+ let changed = false;
4043
+ const content = message.content.map((part) => {
4044
+ const url = fileUrlOf(part);
4045
+ if (!url)
4046
+ return part;
4047
+ if (failed.has(url.href)) {
4048
+ changed = true;
4049
+ const name = part.filename;
4050
+ return {
4051
+ type: "text",
4052
+ text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
4053
+ };
3827
4054
  }
3828
- // The Session base payload is sticky across continuation runs. If it is
3829
- // invalid, returning here would boot the same bad metadata again on the
3830
- // next message. Stay attached and wait for a valid wire frame instead.
3831
- const next = await messagesInput.waitWithIdleTimeout({
3832
- idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
3833
- timeout: "1h",
3834
- spanName: "waiting for valid clientData",
3835
- });
3836
- if (!next.ok || next.output.trigger === "close") {
3837
- return;
4055
+ const downloaded = mediaTypes.get(url.href);
4056
+ if (downloaded && isWildcard(part)) {
4057
+ changed = true;
4058
+ return { ...part, mediaType: downloaded };
3838
4059
  }
3839
- // Normal input frames omit run-level boot context. Carry it forward so
3840
- // a continuation still tells the custom loop to restore prior state.
3841
- const recoveredPayload = {
3842
- ...next.output,
3843
- continuation: next.output.continuation ?? payload.continuation,
3844
- previousRunId: next.output.previousRunId ?? payload.previousRunId,
3845
- sessionId: next.output.sessionId ?? payload.sessionId,
3846
- idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
3847
- };
3848
- return withClose(userRun(recoveredPayload, runOptions));
3849
- },
4060
+ return part;
4061
+ });
4062
+ return changed ? { ...message, content } : message;
3850
4063
  });
3851
- // Register clientDataSchema so the CLI converts it to JSONSchema
3852
- // and stores it as payloadSchema — used by the Playground UI
3853
- if (clientDataSchema) {
3854
- v3_1.resourceCatalog.updateTaskMetadata(options.id, {
3855
- schema: clientDataSchema,
4064
+ }
4065
+ /**
4066
+ * The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
4067
+ * every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
4068
+ * reads the URL itself, fetched from a public host otherwise).
4069
+ */
4070
+ function channelAwareDownload(download, fallback) {
4071
+ return async (requests) => {
4072
+ const results = await Promise.all(requests.map(async (request) => {
4073
+ const file = await download(request.url);
4074
+ return file ? { data: file.data, mediaType: file.mediaType } : null;
4075
+ }));
4076
+ const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
4077
+ if (misses.length === 0)
4078
+ return results;
4079
+ const rest = fallback
4080
+ ? await fallback(misses.map((i) => requests[i]))
4081
+ : await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : (0, modelFileDownload_js_1.fetchPublicFile)(requests[i].url)));
4082
+ misses.forEach((i, n) => {
4083
+ results[i] = rest[n] ?? null;
3856
4084
  });
4085
+ return results;
4086
+ };
4087
+ }
4088
+ /** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
4089
+ function channelClickAction(connectors, wire) {
4090
+ const channelEvent = wire;
4091
+ if (!channelEvent?.connectorId)
4092
+ return undefined;
4093
+ const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
4094
+ if (!connector?.toAction)
4095
+ return undefined;
4096
+ try {
4097
+ return connector.toAction(channelEvent.event) ?? undefined;
4098
+ }
4099
+ catch (error) {
4100
+ v3_1.logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
4101
+ error,
4102
+ });
4103
+ return undefined;
3857
4104
  }
3858
- return task;
3859
4105
  }
3860
- function chatAgent(options) {
3861
- const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
3862
- if (hydrateMessages) {
3863
- if (storage) {
3864
- throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
3865
- "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
3866
- "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
3867
- }
3868
- if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
3869
- throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
3870
- "`loadContext`. Both would own the model's context; keep one.");
3871
- }
3872
- warnHydrateMessagesDeprecatedOnce(options.id);
4106
+ /**
4107
+ * A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
4108
+ * the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
4109
+ * stop or other control, an interaction such as an approval click, an empty message, a connector
4110
+ * this agent does not list), which stays queued as its own turn.
4111
+ */
4112
+ function channelSteeringMessage(connectors, wire, messageId) {
4113
+ const connector = connectors?.find((c) => c.id === wire.connectorId);
4114
+ if (!connector)
4115
+ return undefined;
4116
+ try {
4117
+ if (connector.onControl?.(wire.event))
4118
+ return undefined;
4119
+ if (connector.onInteraction?.(wire.event))
4120
+ return undefined;
4121
+ if (connector.toAction?.(wire.event) != null)
4122
+ return undefined;
4123
+ const inbound = connector.inbound(wire.event);
4124
+ if (isEmptyChannelInbound(inbound))
4125
+ return undefined;
4126
+ const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
4127
+ source: "channel",
4128
+ connectorId: connector.id,
4129
+ provider: connector.source,
4130
+ deliveryId: wire.deliveryId,
4131
+ ...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
4132
+ });
4133
+ return { message };
3873
4134
  }
3874
- const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
3875
- const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
3876
- // chat.agent does not expose generic retry options (see docstring on
3877
- // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
3878
- // `oomMachine` is set we allow one retry on a larger machine; otherwise
3879
- // we keep the historical no-retry default.
3880
- const retry = oomMachine
3881
- ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
3882
- : { maxAttempts: 1 };
3883
- const task = (0, shared_js_1.createTask)({
3884
- ...restOptions,
3885
- retry,
3886
- triggerSource: "agent",
3887
- agentConfig: { type: "ai-sdk-chat" },
3888
- run: async (payload, { signal: runSignal, ctx }) => {
4135
+ catch (error) {
4136
+ v3_1.logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
4137
+ error,
4138
+ });
4139
+ return undefined;
4140
+ }
4141
+ }
4142
+ /**
4143
+ * Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
4144
+ * to answer (an interaction click, an action click, an empty message). Fire and forget.
4145
+ */
4146
+ function acknowledgeChannelMessage(connector, wire, handle) {
4147
+ if (!connector.received)
4148
+ return;
4149
+ try {
4150
+ if (connector.onInteraction?.(wire.event))
4151
+ return;
4152
+ if (connector.toAction?.(wire.event) != null)
4153
+ return;
4154
+ if (isEmptyChannelInbound(connector.inbound(wire.event)))
4155
+ return;
4156
+ void Promise.resolve(connector.received({
4157
+ event: wire.event,
4158
+ deliveryId: wire.deliveryId,
4159
+ handle,
4160
+ bound: false,
4161
+ })).catch(() => { });
4162
+ }
4163
+ catch {
4164
+ return;
4165
+ }
4166
+ }
4167
+ function channelUserMessage(input, messageId, provenance) {
4168
+ const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
4169
+ const message = (wrapped ? input.message : input);
4170
+ const extra = wrapped
4171
+ ? input
4172
+ : {};
4173
+ const meta = {
4174
+ ...provenance,
4175
+ ...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
4176
+ ...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
4177
+ };
4178
+ const base = typeof message === "string"
4179
+ ? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
4180
+ : message.id
4181
+ ? message
4182
+ : { ...message, id: messageId };
4183
+ const existing = base.metadata;
4184
+ return {
4185
+ ...base,
4186
+ metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
4187
+ };
4188
+ }
4189
+ /**
4190
+ * The assistant's closing text for a channel reply: the text after the last tool part in the message,
4191
+ * so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
4192
+ * is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
4193
+ * tool parts (an ordinary turn), so a plain reply is unchanged.
4194
+ */
4195
+ function channelReplyText(message) {
4196
+ const parts = (message.parts ?? []);
4197
+ const isTool = (p) => {
4198
+ const t = p?.type;
4199
+ return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
4200
+ };
4201
+ let lastToolIdx = -1;
4202
+ parts.forEach((p, i) => {
4203
+ if (isTool(p))
4204
+ lastToolIdx = i;
4205
+ });
4206
+ const textFrom = (from) => parts
4207
+ .slice(from)
4208
+ .map((p) => p && typeof p === "object" && p.type === "text"
4209
+ ? String(p.text)
4210
+ : "")
4211
+ .join("");
4212
+ const trailing = textFrom(lastToolIdx + 1);
4213
+ return trailing.trim().length > 0 ? trailing : textFrom(0);
4214
+ }
4215
+ /**
4216
+ * Tries at a turn's final channel reply before it is given up on and the turn shows the error
4217
+ * reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
4218
+ * a post is tried once.
4219
+ */
4220
+ const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
4221
+ /** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
4222
+ function pendingToolCallsInMessage(message) {
4223
+ const out = [];
4224
+ for (const part of (message.parts ?? [])) {
4225
+ if (!part || typeof part !== "object")
4226
+ continue;
4227
+ const type = part.type;
4228
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4229
+ if (!isTool || part.state !== "input-available")
4230
+ continue;
4231
+ const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
4232
+ if (typeof part.toolCallId === "string")
4233
+ out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
4234
+ }
4235
+ return out;
4236
+ }
4237
+ /**
4238
+ * Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
4239
+ * that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
4240
+ * undefined when no pending call of that tool exists.
4241
+ */
4242
+ function resolveInteractionToolCallId(resolution, messages) {
4243
+ if (resolution.toolCallId) {
4244
+ return resolution;
4245
+ }
4246
+ for (let i = messages.length - 1; i >= 0; i--) {
4247
+ const message = messages[i];
4248
+ if (message.role !== "assistant")
4249
+ continue;
4250
+ for (const call of pendingToolCallsInMessage(message).reverse()) {
4251
+ if (call.toolName === resolution.toolName) {
4252
+ return { ...resolution, toolCallId: call.toolCallId };
4253
+ }
4254
+ }
4255
+ }
4256
+ return undefined;
4257
+ }
4258
+ /**
4259
+ * Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
4260
+ * the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
4261
+ * the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
4262
+ */
4263
+ function buildInteractionResolutionMessage(resolution, messages, provenance) {
4264
+ // Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
4265
+ // one assistant message, and a second click on a decided call must not resolve a stale copy.
4266
+ for (const message of messages) {
4267
+ if (message.role !== "assistant")
4268
+ continue;
4269
+ for (const part of (message.parts ?? [])) {
4270
+ if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
4271
+ if (isResolvedToolState(part.state))
4272
+ return undefined;
4273
+ }
4274
+ }
4275
+ }
4276
+ for (let i = messages.length - 1; i >= 0; i--) {
4277
+ const message = messages[i];
4278
+ if (message.role !== "assistant")
4279
+ continue;
4280
+ for (const part of (message.parts ?? [])) {
4281
+ if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
4282
+ continue;
4283
+ const type = part.type;
4284
+ const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
4285
+ if (!isTool)
4286
+ continue;
4287
+ if (part.state !== "input-available")
4288
+ return undefined;
4289
+ const slimPart = {
4290
+ type,
4291
+ toolCallId: resolution.toolCallId,
4292
+ state: "output-available",
4293
+ output: resolution.output,
4294
+ };
4295
+ if (type === "dynamic-tool" && typeof part.toolName === "string")
4296
+ slimPart.toolName = part.toolName;
4297
+ return {
4298
+ id: message.id,
4299
+ role: "assistant",
4300
+ parts: [slimPart],
4301
+ ...(provenance ? { metadata: provenance } : {}),
4302
+ };
4303
+ }
4304
+ }
4305
+ return undefined;
4306
+ }
4307
+ // Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
4308
+ function defaultChannelOutbound(reply) {
4309
+ return reply.text ? { text: reply.text } : null;
4310
+ }
4311
+ // Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
4312
+ // pick one at random from an array. Returns undefined to skip (no choice / empty).
4313
+ async function resolveReactionChoice(choice, event) {
4314
+ if (choice == null)
4315
+ return undefined;
4316
+ let value = typeof choice === "function" ? await choice(event) : choice;
4317
+ if (Array.isArray(value)) {
4318
+ if (value.length === 0)
4319
+ return undefined;
4320
+ value = value[Math.floor(Math.random() * value.length)];
4321
+ }
4322
+ return typeof value === "string" && value.length > 0 ? value : undefined;
4323
+ }
4324
+ // Best-effort reaction on the triggering message; a failure never breaks the turn.
4325
+ async function applyChannelReaction(connector, wireEvent, reaction) {
4326
+ if (!connector?.react || !wireEvent || !reaction.name)
4327
+ return;
4328
+ try {
4329
+ await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
4330
+ }
4331
+ catch (error) {
4332
+ v3_1.logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
4333
+ }
4334
+ }
4335
+ // The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
4336
+ // `react`, so `payload.channel` is only present when reacting is actually possible.
4337
+ function buildChannelRunSurface(connector, wireEvent, handle) {
4338
+ if (!connector)
4339
+ return undefined;
4340
+ const canReact = !!connector.react && !!wireEvent;
4341
+ if (!canReact && !connector.surface)
4342
+ return undefined;
4343
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4344
+ let extra = {};
4345
+ if (connector.surface) {
4346
+ try {
4347
+ extra = connector.surface(turnCtx) ?? {};
4348
+ }
4349
+ catch (error) {
4350
+ v3_1.logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
4351
+ }
4352
+ }
4353
+ const noop = async () => { };
4354
+ return {
4355
+ ...extra,
4356
+ react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
4357
+ unreact: canReact
4358
+ ? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
4359
+ : noop,
4360
+ };
4361
+ }
4362
+ function channelTurnCtx(wireEvent, handle) {
4363
+ return {
4364
+ ...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
4365
+ ...(handle !== undefined ? { handle } : {}),
4366
+ bound: !wireEvent,
4367
+ };
4368
+ }
4369
+ /**
4370
+ * Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
4371
+ * then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
4372
+ * `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
4373
+ * lose the results behind it, and nothing new lands after the turn ended.
4374
+ */
4375
+ function makeChannelProgressObserver(connector, wireEvent, handle) {
4376
+ const progress = connector.progress;
4377
+ const turnCtx = channelTurnCtx(wireEvent, handle);
4378
+ const toolNames = new Map();
4379
+ let chain = Promise.resolve();
4380
+ let stopped = false;
4381
+ const emit = (event) => {
4382
+ chain = chain.then(async () => {
4383
+ try {
4384
+ await progress(event, turnCtx);
4385
+ }
4386
+ catch (error) {
4387
+ v3_1.logger.warn("chat.agent: channel progress() failed; continuing", {
4388
+ error,
4389
+ type: event.type,
4390
+ });
4391
+ }
4392
+ });
4393
+ };
4394
+ return {
4395
+ observe(chunk) {
4396
+ if (stopped)
4397
+ return;
4398
+ const c = chunk;
4399
+ if (!c?.type || !c.toolCallId)
4400
+ return;
4401
+ if (c.type === "tool-input-start" && c.toolName) {
4402
+ toolNames.set(c.toolCallId, c.toolName);
4403
+ return;
4404
+ }
4405
+ if (c.type === "tool-input-available") {
4406
+ const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
4407
+ toolNames.set(c.toolCallId, toolName);
4408
+ emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, input: c.input });
4409
+ return;
4410
+ }
4411
+ if (c.type === "tool-output-available" && !c.preliminary) {
4412
+ emit({
4413
+ type: "tool-result",
4414
+ toolCallId: c.toolCallId,
4415
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4416
+ output: c.output,
4417
+ });
4418
+ return;
4419
+ }
4420
+ if (c.type === "tool-output-error") {
4421
+ emit({
4422
+ type: "tool-error",
4423
+ toolCallId: c.toolCallId,
4424
+ toolName: toolNames.get(c.toolCallId) ?? "tool",
4425
+ errorText: c.errorText ?? "tool failed",
4426
+ });
4427
+ }
4428
+ },
4429
+ async stop() {
4430
+ stopped = true;
4431
+ await chain;
4432
+ },
4433
+ };
4434
+ }
4435
+ /** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
4436
+ const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
4437
+ /**
4438
+ * Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
4439
+ * while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
4440
+ * `working` is still the latest report, so a refresh can never land after the turn went idle.
4441
+ */
4442
+ function makeChannelLifecycleReporter() {
4443
+ let chain = Promise.resolve();
4444
+ let seq = 0;
4445
+ let refreshTimer;
4446
+ let closed = false;
4447
+ const clearRefresh = () => {
4448
+ if (refreshTimer)
4449
+ clearTimeout(refreshTimer);
4450
+ refreshTimer = undefined;
4451
+ };
4452
+ const enqueue = (connector, state, ctx, reportSeq) => {
4453
+ chain = chain.then(async () => {
4454
+ if (ctx.refresh && reportSeq !== seq)
4455
+ return;
4456
+ try {
4457
+ await connector.lifecycle(state, ctx);
4458
+ }
4459
+ catch (error) {
4460
+ v3_1.logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
4461
+ }
4462
+ });
4463
+ return chain;
4464
+ };
4465
+ return {
4466
+ report(connector, state, ctx) {
4467
+ if (!connector?.lifecycle || closed)
4468
+ return Promise.resolve();
4469
+ if (state === "closed")
4470
+ closed = true;
4471
+ const reportSeq = ++seq;
4472
+ clearRefresh();
4473
+ const done = enqueue(connector, state, ctx, reportSeq);
4474
+ if (state === "working") {
4475
+ const arm = () => {
4476
+ refreshTimer = setTimeout(() => {
4477
+ if (reportSeq !== seq)
4478
+ return;
4479
+ void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
4480
+ arm();
4481
+ }, CHANNEL_WORKING_REFRESH_MS);
4482
+ refreshTimer.unref?.();
4483
+ };
4484
+ arm();
4485
+ }
4486
+ return done;
4487
+ },
4488
+ dispose: clearRefresh,
4489
+ };
4490
+ }
4491
+ /**
4492
+ * The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
4493
+ * a stop that arrives while the session waits on a human decision. Undefined when none matched.
4494
+ */
4495
+ function stopPendingToolCalls(message, toolCallIds, errorText) {
4496
+ let changed = false;
4497
+ const parts = (message.parts ?? []).map((part) => {
4498
+ if (part &&
4499
+ typeof part === "object" &&
4500
+ toolCallIds.has(part.toolCallId) &&
4501
+ part.state === "input-available") {
4502
+ changed = true;
4503
+ return { ...part, state: "output-error", errorText };
4504
+ }
4505
+ return part;
4506
+ });
4507
+ return changed ? { ...message, parts } : undefined;
4508
+ }
4509
+ /**
4510
+ * Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
4511
+ * tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
4512
+ * ends the stream itself with the turn's outcome.
4513
+ */
4514
+ function makeChannelReplyStreamObserver(stream) {
4515
+ const toolNames = new Map();
4516
+ let stopped = false;
4517
+ const safe = (fn) => {
4518
+ try {
4519
+ fn();
4520
+ }
4521
+ catch (error) {
4522
+ v3_1.logger.warn("chat.agent: channel reply stream threw; continuing", { error });
4523
+ }
4524
+ };
4525
+ return {
4526
+ observe(chunk) {
4527
+ if (stopped)
4528
+ return;
4529
+ const c = chunk;
4530
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4531
+ const delta = c.delta;
4532
+ safe(() => stream.text(delta));
4533
+ return;
4534
+ }
4535
+ if (!stream.progress || !c?.type || !c.toolCallId)
4536
+ return;
4537
+ const toolCallId = c.toolCallId;
4538
+ if (c.type === "tool-input-start" && c.toolName) {
4539
+ toolNames.set(toolCallId, c.toolName);
4540
+ }
4541
+ else if (c.type === "tool-input-available") {
4542
+ const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
4543
+ toolNames.set(toolCallId, toolName);
4544
+ safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, input: c.input }));
4545
+ }
4546
+ else if (c.type === "tool-output-available" && !c.preliminary) {
4547
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4548
+ safe(() => stream.progress({ type: "tool-result", toolCallId, toolName, output: c.output }));
4549
+ }
4550
+ else if (c.type === "tool-output-error") {
4551
+ const toolName = toolNames.get(toolCallId) ?? "tool";
4552
+ const errorText = c.errorText ?? "tool failed";
4553
+ safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, errorText }));
4554
+ }
4555
+ },
4556
+ stop() {
4557
+ stopped = true;
4558
+ },
4559
+ };
4560
+ }
4561
+ function composeChannelObservers(observers) {
4562
+ return {
4563
+ observe(chunk) {
4564
+ for (const o of observers)
4565
+ o.observe(chunk);
4566
+ },
4567
+ async stop() {
4568
+ await Promise.all(observers.map((o) => o.stop()));
4569
+ },
4570
+ };
4571
+ }
4572
+ // "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
4573
+ // text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
4574
+ // the authoritative last write. Best-effort: an edit failure is logged, never fatal.
4575
+ const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
4576
+ function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
4577
+ const outbound = connector.outbound ?? defaultChannelOutbound;
4578
+ let latest = "";
4579
+ let lastSent = "";
4580
+ let timer;
4581
+ let inFlightPromise;
4582
+ let stopped = false;
4583
+ const arm = () => {
4584
+ if (stopped || timer)
4585
+ return;
4586
+ timer = setTimeout(() => {
4587
+ timer = undefined;
4588
+ void edit();
4589
+ }, CHANNEL_STREAM_EDIT_INTERVAL_MS);
4590
+ };
4591
+ const edit = async () => {
4592
+ if (stopped || inFlightPromise)
4593
+ return;
4594
+ const text = latest;
4595
+ if (text === lastSent)
4596
+ return;
4597
+ const message = outbound({
4598
+ text,
4599
+ message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
4600
+ final: false,
4601
+ stopped: false,
4602
+ });
4603
+ if (!message)
4604
+ return;
4605
+ inFlightPromise = (async () => {
4606
+ try {
4607
+ await connector.send(message, {
4608
+ event: channelEvent.event,
4609
+ handle,
4610
+ deliveryId: channelEvent.deliveryId,
4611
+ previousRef: ackRef,
4612
+ mode: "stream",
4613
+ final: false,
4614
+ bound: false,
4615
+ });
4616
+ lastSent = text;
4617
+ }
4618
+ catch (error) {
4619
+ v3_1.logger.warn("chat.agent: channel stream edit failed", { error });
4620
+ }
4621
+ finally {
4622
+ inFlightPromise = undefined;
4623
+ if (!stopped && latest !== lastSent)
4624
+ arm();
4625
+ }
4626
+ })();
4627
+ await inFlightPromise;
4628
+ };
4629
+ return {
4630
+ observe(chunk) {
4631
+ if (stopped)
4632
+ return;
4633
+ const c = chunk;
4634
+ if (c?.type === "text-delta" && typeof c.delta === "string") {
4635
+ latest += c.delta;
4636
+ arm();
4637
+ }
4638
+ },
4639
+ async stop() {
4640
+ stopped = true;
4641
+ if (timer) {
4642
+ clearTimeout(timer);
4643
+ timer = undefined;
4644
+ }
4645
+ if (inFlightPromise) {
4646
+ try {
4647
+ await inFlightPromise;
4648
+ }
4649
+ catch { }
4650
+ }
4651
+ },
4652
+ };
4653
+ }
4654
+ /**
4655
+ * Wrap the reply stream so it debounce-edits the channel message as it streams.
4656
+ * Both `flush` (the stream completed) and `cancel` (the stream was aborted or
4657
+ * cancelled downstream) stop the editor, so a pending debounce timer can never
4658
+ * fire an edit after the turn ended, onto a message the next turn already owns.
4659
+ */
4660
+ /**
4661
+ * Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
4662
+ * is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
4663
+ * the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
4664
+ * stops it.
4665
+ */
4666
+ function makeChannelStreamTap(editor, options = {}) {
4667
+ const stopOnEnd = options.stopOnEnd ?? true;
4668
+ const transformer = {
4669
+ transform(chunk, controller) {
4670
+ editor.observe(chunk);
4671
+ controller.enqueue(chunk);
4672
+ },
4673
+ async flush() {
4674
+ if (stopOnEnd)
4675
+ await editor.stop();
4676
+ },
4677
+ async cancel() {
4678
+ if (stopOnEnd)
4679
+ await editor.stop();
4680
+ },
4681
+ };
4682
+ return new TransformStream(transformer);
4683
+ }
4684
+ /**
4685
+ * Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
4686
+ * final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
4687
+ * settles it once that egress has finished, so a caller that saw the chunk can wait for the
4688
+ * connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
4689
+ * no open latch is quiet.
4690
+ */
4691
+ const channelEgressLatches = new Map();
4692
+ function openChannelEgressLatch(chatId) {
4693
+ let settle;
4694
+ const promise = new Promise((resolve) => (settle = resolve));
4695
+ channelEgressLatches.set(chatId, { promise, settle });
4696
+ }
4697
+ function settleChannelEgressLatch(chatId) {
4698
+ const latch = channelEgressLatches.get(chatId);
4699
+ if (!latch)
4700
+ return;
4701
+ channelEgressLatches.delete(chatId);
4702
+ latch.settle();
4703
+ }
4704
+ /** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
4705
+ async function __waitForChannelEgressForTests(chatId) {
4706
+ await channelEgressLatches.get(chatId)?.promise;
4707
+ }
4708
+ function chatCustomAgent(options) {
4709
+ const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
4710
+ const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
4711
+ const parseClientDataSync = clientDataSchema
4712
+ ? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
4713
+ : undefined;
4714
+ const task = (0, shared_js_1.createTask)({
4715
+ ...restOptions,
4716
+ triggerSource: "agent",
4717
+ agentConfig: { type: "ai-sdk-chat" },
4718
+ run: async (payload, runOptions) => {
4719
+ // Bind the run to its backing Session so module-level helpers
4720
+ // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
4721
+ // resolve to this chat's `.in` / `.out` channels. Address
4722
+ // everywhere by `payload.chatId` (the session externalId) so the
4723
+ // agent's writes and the transport's reads converge on the same
4724
+ // S2 stream key + waitpoint key.
4725
+ //
4726
+ // The Session row is created server-side by `POST /sessions` (or
4727
+ // `chat.createStartSessionAction`) before this run is triggered.
4728
+ // No client-side upsert needed.
4729
+ locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
4730
+ locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
4731
+ locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
4732
+ locals_js_1.locals.set(chatCustomAgentRunKey, true);
4733
+ if (parseClientData && parseClientDataSync) {
4734
+ locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
4735
+ parse: parseClientData,
4736
+ parseSync: parseClientDataSync,
4737
+ });
4738
+ }
4739
+ if (onClientDataValidationError) {
4740
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
4741
+ }
4742
+ locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
4743
+ // Initialize the turn-complete trim slot so `chat.writeTurnComplete`
4744
+ // trims `session.out` back to the previous turn boundary. Without
4745
+ // this the slot is undefined and the trim never runs, so `.out`
4746
+ // grows without bound for the whole custom-agent surface.
4747
+ locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
4748
+ (0, streams_js_1.markChatAgentRunForStreamsWarning)();
4749
+ v3_1.taskContext.setConversationId(payload.chatId);
4750
+ stampConversationIdOnActiveSpan(payload.chatId);
4751
+ await installChatInputRouter(payload.chatId, {
4752
+ resuming: Boolean(payload.continuation),
4753
+ });
4754
+ // A custom agent's loop is the customer's, so there is no exit site the
4755
+ // SDK controls. Perform a requested close when `run()` returns, whatever
4756
+ // shape the loop had. Idempotent, so the createSession iterator having
4757
+ // already closed on its own exit costs nothing.
4758
+ const withClose = async (result) => {
4759
+ try {
4760
+ return await result;
4761
+ }
4762
+ finally {
4763
+ await performChatClose();
4764
+ }
4765
+ };
4766
+ // Keep the schema-free path identical to the original custom-agent
4767
+ // wrapper, including when userRun starts executing.
4768
+ if (!parseClientData) {
4769
+ return withClose(userRun(payload, runOptions));
4770
+ }
4771
+ const isHandoverBoot = payload.trigger === "handover-prepare";
4772
+ const isMessagelessBoot = payload.trigger === "preload" ||
4773
+ (payload.continuation === true &&
4774
+ payload.message === undefined &&
4775
+ payload.trigger !== "action" &&
4776
+ payload.trigger !== "regenerate-message" &&
4777
+ !isHandoverBoot);
4778
+ const validated = await validateChatCustomAgentPayload(payload, {
4779
+ // Preload and continuation boots do not represent a submitted turn,
4780
+ // so there is no sender waiting for a terminal frame. Handover errors
4781
+ // must be written after the warm response flushes and signals below.
4782
+ writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
4783
+ });
4784
+ if (validated.ok) {
4785
+ return withClose(userRun(validated.payload, runOptions));
4786
+ }
4787
+ if (isHandoverBoot) {
4788
+ const signal = await waitForHandover({
4789
+ payload,
4790
+ timeout: "1h",
4791
+ spanName: "waiting for handover signal (invalid clientData)",
4792
+ });
4793
+ if (!signal || signal.kind === "handover-skip") {
4794
+ return;
4795
+ }
4796
+ // The head-start writer flushes before sending this signal. Writing
4797
+ // the terminal error now preserves stream order and closes the stitch.
4798
+ await writeChatCustomAgentClientDataErrorToStream(payload);
4799
+ return;
4800
+ }
4801
+ // The Session base payload is sticky across continuation runs. If it is
4802
+ // invalid, returning here would boot the same bad metadata again on the
4803
+ // next message. Stay attached and wait for a valid wire frame instead.
4804
+ const next = await messagesInput.waitWithIdleTimeout({
4805
+ idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
4806
+ timeout: "1h",
4807
+ spanName: "waiting for valid clientData",
4808
+ });
4809
+ if (!next.ok || next.output.trigger === "close") {
4810
+ return;
4811
+ }
4812
+ // Normal input frames omit run-level boot context. Carry it forward so
4813
+ // a continuation still tells the custom loop to restore prior state.
4814
+ const recoveredPayload = {
4815
+ ...next.output,
4816
+ continuation: next.output.continuation ?? payload.continuation,
4817
+ previousRunId: next.output.previousRunId ?? payload.previousRunId,
4818
+ sessionId: next.output.sessionId ?? payload.sessionId,
4819
+ idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
4820
+ };
4821
+ return withClose(userRun(recoveredPayload, runOptions));
4822
+ },
4823
+ });
4824
+ // Register clientDataSchema so the CLI converts it to JSONSchema
4825
+ // and stores it as payloadSchema — used by the Playground UI
4826
+ if (clientDataSchema) {
4827
+ v3_1.resourceCatalog.updateTaskMetadata(options.id, {
4828
+ schema: clientDataSchema,
4829
+ });
4830
+ }
4831
+ return task;
4832
+ }
4833
+ function chatAgent(options) {
4834
+ 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;
4835
+ if (hydrateMessages) {
4836
+ if (storage) {
4837
+ throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
4838
+ "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
4839
+ "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
4840
+ }
4841
+ if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
4842
+ throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
4843
+ "`loadContext`. Both would own the model's context; keep one.");
4844
+ }
4845
+ warnHydrateMessagesDeprecatedOnce(options.id);
4846
+ }
4847
+ const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
4848
+ const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
4849
+ // chat.agent does not expose generic retry options (see docstring on
4850
+ // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
4851
+ // `oomMachine` is set we allow one retry on a larger machine; otherwise
4852
+ // we keep the historical no-retry default.
4853
+ const retry = oomMachine
4854
+ ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
4855
+ : { maxAttempts: 1 };
4856
+ const task = (0, shared_js_1.createTask)({
4857
+ ...restOptions,
4858
+ retry,
4859
+ triggerSource: "agent",
4860
+ agentConfig: { type: "ai-sdk-chat" },
4861
+ run: async (payload, { signal: runSignal, ctx }) => {
3889
4862
  locals_js_1.locals.set(chatAgentRunContextKey, ctx);
3890
4863
  locals_js_1.locals.set(chatCustomAgentRunKey, false);
3891
4864
  // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
@@ -4024,6 +4997,69 @@ function chatAgent(options) {
4024
4997
  : undefined;
4025
4998
  let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
4026
4999
  let bootTranscriptState = null;
5000
+ /**
5001
+ * The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
5002
+ * whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
5003
+ */
5004
+ let sessionChannelBinding;
5005
+ const channelDownloader = channels
5006
+ ? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
5007
+ ? sessionChannelBinding.handle
5008
+ : undefined)
5009
+ : undefined;
5010
+ locals_js_1.locals.set(chatChannelDownloadKey, channelDownloader);
5011
+ /**
5012
+ * Tool calls that already have channel controls posted. A turn that resumes with one of
5013
+ * several pending calls resolved still carries the others as pending; without this the
5014
+ * controls would be posted again, and the model's final text would lose to them. A
5015
+ * continuation worker seeds it at boot from the calls still pending in the restored
5016
+ * transcript of a channel-bound session, since the worker before it posted their controls.
5017
+ */
5018
+ const channelRenderedToolCallIds = new Set();
5019
+ /** Serializes `lifecycle()` reports for the session's channel across turns. */
5020
+ const channelLifecycle = makeChannelLifecycleReporter();
5021
+ /**
5022
+ * Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
5023
+ * is dropped even when the transcript was hydrated again from a store that never saw the stop.
5024
+ */
5025
+ const channelStoppedToolCallIds = new Set();
5026
+ /** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
5027
+ let lastChannelTurnStartedAt;
5028
+ /** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
5029
+ let lastChannelTurnEndedAt;
5030
+ /** The turn reported its end state: the binding no longer marks the surface as working. */
5031
+ const clearChannelWorking = () => {
5032
+ if (!sessionChannelBinding?.working)
5033
+ return;
5034
+ const { working: _working, ...rest } = sessionChannelBinding;
5035
+ void _working;
5036
+ sessionChannelBinding = rest;
5037
+ };
5038
+ /** The turn's reply stream ended: the binding no longer names it for recovery. */
5039
+ const clearOpenReplyStream = () => {
5040
+ if (!sessionChannelBinding?.stream)
5041
+ return;
5042
+ const { stream: _stream, ...rest } = sessionChannelBinding;
5043
+ void _stream;
5044
+ sessionChannelBinding = rest;
5045
+ };
5046
+ /** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
5047
+ const reportChannelClosed = async () => {
5048
+ const binding = sessionChannelBinding;
5049
+ if (!binding)
5050
+ return;
5051
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5052
+ await channelLifecycle.report(connector, "closed", {
5053
+ handle: binding.handle,
5054
+ bound: true,
5055
+ firstTurn: false,
5056
+ });
5057
+ };
5058
+ /** Provenance of the turn or action being processed, for the storage context. */
5059
+ let currentTurnSource = "client";
5060
+ let currentTurnConnectorId;
5061
+ /** True once `onAction` returned `chat.turn()` for the action being processed. */
5062
+ let turnIsActionTurn = false;
4027
5063
  /**
4028
5064
  * True while the model lane holds a compaction summary, so it cannot be
4029
5065
  * rebuilt from the transcript and has to be persisted as state. Reset
@@ -4086,9 +5122,14 @@ function chatAgent(options) {
4086
5122
  const transcriptIds = new Set(opts.messages.map((message) => message.id));
4087
5123
  const markerIds = new Set((0, steeringContext_js_1.steeringMarkers)(opts.messages).map((marker) => marker.id));
4088
5124
  const steering = [...(locals_js_1.locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
4089
- const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0 || steering.length > 0
5125
+ const runtimeState = laneCompacted ||
5126
+ laneInjections.length > 0 ||
5127
+ queued.length > 0 ||
5128
+ steering.length > 0 ||
5129
+ sessionChannelBinding
4090
5130
  ? {
4091
5131
  v: 1,
5132
+ ...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
4092
5133
  ...(laneCompacted
4093
5134
  ? {
4094
5135
  compaction: {
@@ -4118,7 +5159,9 @@ function chatAgent(options) {
4118
5159
  chatId: payload.chatId,
4119
5160
  clientData: opts.clientData,
4120
5161
  turn: opts.turn,
4121
- trigger: opts.trigger,
5162
+ trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
5163
+ source: currentTurnSource,
5164
+ ...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
4122
5165
  runId: ctx.run.id,
4123
5166
  ctx,
4124
5167
  }, {
@@ -4201,6 +5244,9 @@ function chatAgent(options) {
4201
5244
  let replayedPartial;
4202
5245
  let replayedPartialRaw;
4203
5246
  let replayedInTail = [];
5247
+ // Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
5248
+ // they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
5249
+ let replayedChannelInTail = [];
4204
5250
  // Wire payloads to dispatch as turns before the regular session.in
4205
5251
  // pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
4206
5252
  // default, `inFlightUsers`). The turn-loop checks this queue ahead of
@@ -4331,9 +5377,11 @@ function chatAgent(options) {
4331
5377
  }
4332
5378
  bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
4333
5379
  try {
4334
- replayedInTail = await replaySessionInTail(payload.chatId, {
5380
+ const tail = await replaySessionInTailWithChannels(payload.chatId, {
4335
5381
  lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
4336
5382
  });
5383
+ replayedInTail = tail.messages;
5384
+ replayedChannelInTail = tail.channelEvents;
4337
5385
  }
4338
5386
  catch (error) {
4339
5387
  v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
@@ -4354,7 +5402,10 @@ function chatAgent(options) {
4354
5402
  }
4355
5403
  // ── session.in router ──────────────────────────────────────────
4356
5404
  //
4357
- const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
5405
+ const recoveredSeqNums = [
5406
+ ...replayedInTail.map((r) => r.seqNum),
5407
+ ...replayedChannelInTail.map((r) => r.seqNum),
5408
+ ];
4358
5409
  await installChatInputRouter(payload.chatId, {
4359
5410
  fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
4360
5411
  recoveredSeqNums,
@@ -4371,6 +5422,9 @@ function chatAgent(options) {
4371
5422
  // predecessor chose to end before processing the message;
4372
5423
  // those route through the normal continuation-wait path.
4373
5424
  const hasRecoveredState = partialAssistant !== undefined;
5425
+ if (couldHavePriorState && hasRecoveredState) {
5426
+ locals_js_1.locals.set(chatChannelRecoveryPendingKey, { value: true });
5427
+ }
4374
5428
  let hookChain;
4375
5429
  let hookRecoveredTurns;
4376
5430
  let hookBeforeBoot;
@@ -4441,10 +5495,18 @@ function chatAgent(options) {
4441
5495
  // user is re-dispatched as a fresh turn instead.
4442
5496
  let seedChain;
4443
5497
  let recoveredEntries;
5498
+ // The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
5499
+ // which only makes sense when that record is the browser message it was answering. When a
5500
+ // channel delivery is older than every browser message, the partial belongs to it instead, and
5501
+ // splicing the first browser message ahead of it would reorder the conversation.
5502
+ const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
5503
+ const partialFollowsBrowserMessage = partialAssistant !== undefined &&
5504
+ inFlightUsers.length > 1 &&
5505
+ (replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
4444
5506
  if (hookChain !== undefined) {
4445
5507
  seedChain = hookChain;
4446
5508
  }
4447
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5509
+ else if (partialFollowsBrowserMessage) {
4448
5510
  seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
4449
5511
  }
4450
5512
  else {
@@ -4464,7 +5526,7 @@ function chatAgent(options) {
4464
5526
  seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
4465
5527
  }));
4466
5528
  }
4467
- else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
5529
+ else if (partialFollowsBrowserMessage) {
4468
5530
  recoveredEntries = replayedInTail
4469
5531
  .slice(1)
4470
5532
  .map((r) => ({ message: r.message, seqNum: r.seqNum }));
@@ -4501,6 +5563,7 @@ function chatAgent(options) {
4501
5563
  metadataById.set(entry.message.id, entry.metadata);
4502
5564
  }
4503
5565
  const dispatchedRecoveredSeqs = new Set();
5566
+ const toInject = [];
4504
5567
  for (const { message: msg, seqNum } of recoveredEntries) {
4505
5568
  if (wireMessageId && msg.id === wireMessageId)
4506
5569
  continue;
@@ -4512,18 +5575,42 @@ function chatAgent(options) {
4512
5575
  sessionId: payload.sessionId,
4513
5576
  metadata: recoveredMetadata,
4514
5577
  trigger: "submit-message",
4515
- message: msg,
4516
- messageId: msg.id,
5578
+ message: msg,
5579
+ messageId: msg.id,
5580
+ continuation: payload.continuation,
5581
+ previousRunId: payload.previousRunId,
5582
+ };
5583
+ toInject.push({ injectedPayload, seqNum });
5584
+ }
5585
+ // Channel deliveries the predecessor never processed dispatch too, interleaved with the
5586
+ // browser messages by seq_num so a Slack message that arrived before a browser message
5587
+ // still runs before it. A delivery the boot payload itself carries is not queued twice.
5588
+ const wireDeliveryId = payload.channelEvent
5589
+ ?.deliveryId;
5590
+ for (const entry of replayedChannelInTail) {
5591
+ const entryDeliveryId = entry.channelEvent.deliveryId;
5592
+ if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
5593
+ continue;
5594
+ const injectedPayload = {
5595
+ chatId: payload.chatId,
5596
+ sessionId: payload.sessionId,
5597
+ metadata: entry.metadata ?? payload.metadata,
5598
+ trigger: "submit-message",
5599
+ channelEvent: entry.channelEvent,
4517
5600
  continuation: payload.continuation,
4518
5601
  previousRunId: payload.previousRunId,
4519
5602
  };
5603
+ toInject.push({ injectedPayload, seqNum: entry.seqNum });
5604
+ }
5605
+ toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
5606
+ for (const { injectedPayload, seqNum } of toInject) {
4520
5607
  bootInjectedQueue.push(injectedPayload);
4521
5608
  if (seqNum !== undefined) {
4522
5609
  recoveredSeqByPayload.set(injectedPayload, seqNum);
4523
5610
  dispatchedRecoveredSeqs.add(seqNum);
4524
5611
  }
4525
5612
  }
4526
- for (const entry of replayedInTail) {
5613
+ for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
4527
5614
  if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
4528
5615
  chatInputRouter().settleRecovered(entry.seqNum);
4529
5616
  }
@@ -4575,6 +5662,38 @@ function chatAgent(options) {
4575
5662
  accumulatedMessages = restored.messages;
4576
5663
  laneCompacted = restored.compacted;
4577
5664
  laneInjections = restored.injections;
5665
+ sessionChannelBinding = bootRuntimeState?.channelBinding;
5666
+ const interruptedStream = sessionChannelBinding?.stream;
5667
+ if (interruptedStream && sessionChannelBinding) {
5668
+ const binding = sessionChannelBinding;
5669
+ const connector = channels?.find((c) => c.id === binding.connectorId);
5670
+ clearOpenReplyStream();
5671
+ if (connector?.endInterruptedStream) {
5672
+ try {
5673
+ await connector.endInterruptedStream(interruptedStream, {
5674
+ handle: binding.handle,
5675
+ bound: true,
5676
+ });
5677
+ }
5678
+ catch (error) {
5679
+ v3_1.logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
5680
+ }
5681
+ }
5682
+ }
5683
+ if (sessionChannelBinding?.working) {
5684
+ const binding = sessionChannelBinding;
5685
+ clearChannelWorking();
5686
+ await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
5687
+ }
5688
+ if (sessionChannelBinding) {
5689
+ for (const message of accumulatedUIMessages) {
5690
+ if (message.role !== "assistant")
5691
+ continue;
5692
+ for (const call of pendingToolCallsInMessage(message)) {
5693
+ channelRenderedToolCallIds.add(call.toolCallId);
5694
+ }
5695
+ }
5696
+ }
4578
5697
  if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
4579
5698
  locals_js_1.locals.set(chatBackgroundQueueKey, [
4580
5699
  ...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
@@ -4961,26 +6080,432 @@ function chatAgent(options) {
4961
6080
  // Declared here so the finally can detach it — a handler leaked past
4962
6081
  // its turn duplicates every mid-stream message into the shared buffer.
4963
6082
  let turnMsgSub;
6083
+ let turnControlSub;
4964
6084
  let capturedPartialResponse;
4965
6085
  let responseCommitted = false;
4966
6086
  const turnBufferedChunks = [];
6087
+ let channelConn;
6088
+ let channelWorkingReaction;
6089
+ let channelWireEvent;
6090
+ /** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
6091
+ let channelHandle;
6092
+ /** True when no channel delivery started this turn and egress goes through the session binding. */
6093
+ let boundTurn = false;
6094
+ let channelAckRef;
6095
+ let channelStreamEditor;
6096
+ let droppedStaleInteraction = false;
6097
+ /** True when the connector mapped this delivery to an empty message: no turn runs. */
6098
+ let droppedEmptyMessage = false;
6099
+ let droppedUnknownConnector = false;
6100
+ /** True when this delivery was a stop the connector drops while idle: no turn runs. */
6101
+ let droppedIdleControl = false;
6102
+ /** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
6103
+ let channelReplyStream;
6104
+ /** True once this turn reported `working`, so its end reports the outcome. */
6105
+ let channelLifecycleStarted = false;
6106
+ /** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
6107
+ const channelTurnStartedAt = Date.now();
6108
+ /** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
6109
+ let channelTurnRan = false;
6110
+ /** True when this turn resolves a pending tool call from a channel interaction callback. */
6111
+ let channelInteractionResolved = false;
6112
+ let channelFinalAnswerPosted = false;
6113
+ let channelFinalSendFailed = false;
6114
+ /** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
6115
+ let channelStreamErrorText;
6116
+ /** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
6117
+ let channelTurnText;
4967
6118
  try {
4968
6119
  // Extract turn-level context before entering the span. Slim
4969
6120
  // wire: at most one delta message per record. `headStartMessages`
4970
6121
  // is consumed at boot only (via `payload.headStartMessages`)
4971
6122
  // and intentionally discarded here.
4972
- const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, ...restWire } = currentWirePayload;
6123
+ const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
6124
+ let actionChannel;
6125
+ if (clickedAction !== undefined) {
6126
+ const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
6127
+ const click = clicked;
6128
+ actionChannel = {
6129
+ connectorId: click.connectorId,
6130
+ provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
6131
+ event: click.event,
6132
+ deliveryId: click.deliveryId,
6133
+ };
6134
+ currentWirePayload = {
6135
+ ...withoutChannelEvent,
6136
+ trigger: "action",
6137
+ action: clickedAction,
6138
+ actionSource: "channel",
6139
+ };
6140
+ }
6141
+ const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
4973
6142
  void _hsm;
4974
- const incomingMessages = incomingMessage
4975
- ? [incomingMessage]
6143
+ channelWireEvent = wireChannelEvent;
6144
+ let effectiveIncomingMessage = incomingMessage;
6145
+ const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
6146
+ turnClientData = clientData;
6147
+ turnIsActionTurn = false;
6148
+ currentTurnSource = wireChannelEvent
6149
+ ? "channel"
6150
+ : currentWirePayload.actionSource === "webhook"
6151
+ ? "webhook"
6152
+ : "client";
6153
+ currentTurnConnectorId = undefined;
6154
+ if (wireChannelEvent) {
6155
+ channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
6156
+ if (channelConn) {
6157
+ currentTurnConnectorId = channelConn.id;
6158
+ let idleControl = null;
6159
+ if (channelConn.onControl) {
6160
+ try {
6161
+ idleControl = channelConn.onControl(wireChannelEvent.event);
6162
+ }
6163
+ catch (controlError) {
6164
+ v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
6165
+ error: controlError,
6166
+ });
6167
+ }
6168
+ }
6169
+ const stopForEndedTurn = idleControl?.at !== undefined &&
6170
+ lastChannelTurnEndedAt !== undefined &&
6171
+ idleControl.at < lastChannelTurnEndedAt;
6172
+ droppedIdleControl =
6173
+ idleControl?.kind === "stop" &&
6174
+ (idleControl.whenIdle === "drop" || stopForEndedTurn);
6175
+ // Bind the session to this connector so a later turn that no channel delivery
6176
+ // started (an action turn, a browser message) can still answer into the thread.
6177
+ if (channelConn.bind && !droppedIdleControl) {
6178
+ try {
6179
+ const handle = channelConn.bind(wireChannelEvent.event);
6180
+ if (handle !== null && handle !== undefined) {
6181
+ const controls = sessionChannelBinding?.connectorId === channelConn.id
6182
+ ? sessionChannelBinding.controls
6183
+ : undefined;
6184
+ sessionChannelBinding = {
6185
+ connectorId: channelConn.id,
6186
+ handle,
6187
+ boundAt: new Date().toISOString(),
6188
+ deliveryId: wireChannelEvent.deliveryId,
6189
+ ...(controls ? { controls } : {}),
6190
+ };
6191
+ }
6192
+ }
6193
+ catch (bindError) {
6194
+ v3_1.logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
6195
+ error: bindError,
6196
+ });
6197
+ }
6198
+ }
6199
+ channelHandle =
6200
+ sessionChannelBinding?.connectorId === channelConn.id
6201
+ ? sessionChannelBinding.handle
6202
+ : undefined;
6203
+ const rawInteraction = droppedIdleControl
6204
+ ? null
6205
+ : (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
6206
+ let interaction = rawInteraction
6207
+ ? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
6208
+ : null;
6209
+ const accumulatorHasInteractionToolCall = interaction != null &&
6210
+ accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
6211
+ if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
6212
+ try {
6213
+ const hydratedForInteraction = (await loadContextHook({
6214
+ chatId: currentWirePayload.chatId,
6215
+ turn,
6216
+ trigger: "submit-message",
6217
+ incomingMessages: [],
6218
+ previousMessages: [...accumulatedUIMessages],
6219
+ clientData,
6220
+ continuation,
6221
+ previousRunId,
6222
+ }));
6223
+ accumulatedUIMessages = hydratedForInteraction;
6224
+ accumulatedMessages = await toModelMessages(hydratedForInteraction);
6225
+ laneCompacted = false;
6226
+ laneInjections = [];
6227
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6228
+ if (!interaction) {
6229
+ interaction =
6230
+ resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
6231
+ }
6232
+ }
6233
+ catch (hydrateError) {
6234
+ v3_1.logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
6235
+ }
6236
+ }
6237
+ const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
6238
+ ? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
6239
+ source: "channel",
6240
+ connectorId: channelConn.id,
6241
+ provider: channelConn.source,
6242
+ deliveryId: wireChannelEvent.deliveryId,
6243
+ })
6244
+ : undefined;
6245
+ const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
6246
+ ? undefined
6247
+ : channelConn.inbound(wireChannelEvent.event);
6248
+ if (droppedIdleControl) {
6249
+ const staleStop = idleControl?.at !== undefined &&
6250
+ lastChannelTurnStartedAt !== undefined &&
6251
+ idleControl.at < lastChannelTurnStartedAt;
6252
+ if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
6253
+ try {
6254
+ const hydratedForStop = (await loadContextHook({
6255
+ chatId: currentWirePayload.chatId,
6256
+ turn,
6257
+ trigger: "submit-message",
6258
+ incomingMessages: [],
6259
+ previousMessages: [],
6260
+ clientData,
6261
+ continuation,
6262
+ previousRunId,
6263
+ }));
6264
+ accumulatedUIMessages = hydratedForStop;
6265
+ accumulatedMessages = await toModelMessages(hydratedForStop);
6266
+ laneCompacted = false;
6267
+ laneInjections = [];
6268
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6269
+ }
6270
+ catch (hydrateError) {
6271
+ v3_1.logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
6272
+ error: hydrateError,
6273
+ });
6274
+ }
6275
+ }
6276
+ let lastAssistantIndex = -1;
6277
+ for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
6278
+ if (accumulatedUIMessages[i].role === "assistant") {
6279
+ lastAssistantIndex = i;
6280
+ break;
6281
+ }
6282
+ }
6283
+ const lastAssistant = lastAssistantIndex === -1
6284
+ ? undefined
6285
+ : accumulatedUIMessages[lastAssistantIndex];
6286
+ const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
6287
+ ? pendingToolCallsInMessage(lastAssistant)
6288
+ : [];
6289
+ if (lastAssistant && stoppedCalls.length > 0) {
6290
+ const errorText = idleControl?.message || "stopped";
6291
+ const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
6292
+ if (stoppedMessage) {
6293
+ accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
6294
+ const pausedRun = await toModelMessages([
6295
+ stripProviderMetadata(lastAssistant),
6296
+ ]);
6297
+ if (pausedRun.length === 0) {
6298
+ accumulatedMessages = [
6299
+ ...accumulatedMessages,
6300
+ ...(await toModelMessages([stoppedMessage])),
6301
+ ];
6302
+ }
6303
+ else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
6304
+ v3_1.logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
6305
+ accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
6306
+ laneCompacted = false;
6307
+ laneInjections = [];
6308
+ }
6309
+ for (const call of stoppedCalls)
6310
+ channelStoppedToolCallIds.add(call.toolCallId);
6311
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6312
+ }
6313
+ const refs = sessionChannelBinding?.connectorId === channelConn.id
6314
+ ? sessionChannelBinding.controls
6315
+ : undefined;
6316
+ const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
6317
+ for (const call of stoppedCalls) {
6318
+ channelRenderedToolCallIds.delete(call.toolCallId);
6319
+ }
6320
+ if (refs && sessionChannelBinding) {
6321
+ const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
6322
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6323
+ void _controls;
6324
+ sessionChannelBinding =
6325
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6326
+ }
6327
+ await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
6328
+ if (channelConn.cancelInteraction) {
6329
+ try {
6330
+ await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
6331
+ }
6332
+ catch (cancelError) {
6333
+ v3_1.logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
6334
+ error: cancelError,
6335
+ });
6336
+ }
6337
+ }
6338
+ await channelLifecycle.report(channelConn, "idle", {
6339
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6340
+ firstTurn: false,
6341
+ });
6342
+ }
6343
+ }
6344
+ else if (resolutionMessage) {
6345
+ effectiveIncomingMessage = resolutionMessage;
6346
+ channelInteractionResolved = true;
6347
+ const resolvedCallId = interaction?.toolCallId;
6348
+ const controlRefs = sessionChannelBinding?.controls;
6349
+ if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
6350
+ const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
6351
+ void _resolved;
6352
+ const { controls: _controls, ...rest } = sessionChannelBinding;
6353
+ void _controls;
6354
+ sessionChannelBinding =
6355
+ Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
6356
+ }
6357
+ if (interaction && channelConn.finalizeInteraction) {
6358
+ try {
6359
+ await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
6360
+ }
6361
+ catch (finalizeError) {
6362
+ v3_1.logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
6363
+ error: finalizeError,
6364
+ });
6365
+ }
6366
+ }
6367
+ }
6368
+ else if (rawInteraction) {
6369
+ droppedStaleInteraction = true;
6370
+ v3_1.logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
6371
+ deliveryId: wireChannelEvent.deliveryId,
6372
+ });
6373
+ }
6374
+ else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
6375
+ droppedEmptyMessage = true;
6376
+ v3_1.logger.debug("chat.agent: dropping a channel delivery with an empty message", {
6377
+ deliveryId: wireChannelEvent.deliveryId,
6378
+ });
6379
+ }
6380
+ else {
6381
+ // A session that starts on a conversation with history: let the connector supply
6382
+ // it, so the agent answers with the context a reader of the thread has.
6383
+ if (channelConn.seed && accumulatedUIMessages.length === 0) {
6384
+ try {
6385
+ const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
6386
+ event: wireChannelEvent.event,
6387
+ deliveryId: wireChannelEvent.deliveryId,
6388
+ }), {
6389
+ source: "channel",
6390
+ connectorId: channelConn.id,
6391
+ provider: channelConn.source,
6392
+ deliveryId: wireChannelEvent.deliveryId,
6393
+ });
6394
+ if (seeded.length > 0) {
6395
+ accumulatedUIMessages = seeded;
6396
+ accumulatedMessages = await toModelMessages(seeded);
6397
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6398
+ }
6399
+ }
6400
+ catch (seedError) {
6401
+ v3_1.logger.warn("chat.agent: channel seed() failed; starting the session empty", {
6402
+ error: seedError,
6403
+ });
6404
+ }
6405
+ }
6406
+ effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
6407
+ source: "channel",
6408
+ connectorId: channelConn.id,
6409
+ provider: channelConn.source,
6410
+ deliveryId: wireChannelEvent.deliveryId,
6411
+ ...(wireChannelEvent.externalDeliveryId
6412
+ ? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
6413
+ : {}),
6414
+ });
6415
+ if (channelConn.send && channelConn.ack) {
6416
+ const recoveryPending = locals_js_1.locals.get(chatChannelRecoveryPendingKey);
6417
+ const recovered = recoveryPending?.value === true;
6418
+ if (recovered)
6419
+ recoveryPending.value = false;
6420
+ const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
6421
+ if (ackMessage) {
6422
+ try {
6423
+ const ackResult = await channelConn.send(ackMessage, {
6424
+ event: wireChannelEvent.event,
6425
+ handle: channelHandle,
6426
+ deliveryId: wireChannelEvent.deliveryId,
6427
+ mode: channelConn.delivery,
6428
+ final: false,
6429
+ bound: false,
6430
+ });
6431
+ channelAckRef = ackResult?.ref;
6432
+ }
6433
+ catch (ackError) {
6434
+ v3_1.logger.warn("chat.agent: channel ack post failed; continuing", {
6435
+ error: ackError,
6436
+ });
6437
+ }
6438
+ }
6439
+ }
6440
+ try {
6441
+ channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
6442
+ if (channelWorkingReaction) {
6443
+ await applyChannelReaction(channelConn, wireChannelEvent, {
6444
+ name: channelWorkingReaction,
6445
+ });
6446
+ }
6447
+ }
6448
+ catch (reactionError) {
6449
+ v3_1.logger.warn("chat.agent: channel working reaction failed", {
6450
+ error: reactionError,
6451
+ });
6452
+ }
6453
+ }
6454
+ }
6455
+ else {
6456
+ droppedUnknownConnector = true;
6457
+ v3_1.logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
6458
+ connectorId: wireChannelEvent.connectorId,
6459
+ deliveryId: wireChannelEvent.deliveryId,
6460
+ });
6461
+ }
6462
+ }
6463
+ if (!wireChannelEvent &&
6464
+ sessionChannelBinding &&
6465
+ currentWirePayload.trigger !== "close" &&
6466
+ currentWirePayload.trigger !== "preload") {
6467
+ // No channel delivery started this turn, but the session is bound to a channel: answer
6468
+ // into it. Reactions need the triggering message, so a bound turn adds none; its reply,
6469
+ // any error and any approval controls go to the bound thread.
6470
+ const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
6471
+ if (bound?.send) {
6472
+ channelConn = bound;
6473
+ channelHandle = sessionChannelBinding.handle;
6474
+ boundTurn = true;
6475
+ currentTurnConnectorId = bound.id;
6476
+ }
6477
+ }
6478
+ const incomingMessages = effectiveIncomingMessage
6479
+ ? [effectiveIncomingMessage]
4976
6480
  : [];
4977
6481
  // Cleaning happens once here so `extractLastUserMessageText` and
4978
6482
  // every downstream consumer see the same message shape — and
4979
6483
  // `cleanupAbortedParts` no longer has to be re-applied below.
4980
6484
  const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
4981
- const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
4982
- turnClientData = clientData;
4983
6485
  const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
6486
+ channelTurnRan =
6487
+ !!channelConn &&
6488
+ !!(wireChannelEvent || boundTurn) &&
6489
+ currentWirePayload.trigger !== "action" &&
6490
+ !droppedIdleControl &&
6491
+ !droppedStaleInteraction &&
6492
+ !droppedEmptyMessage;
6493
+ const startChannelLifecycle = async () => {
6494
+ lastChannelTurnStartedAt = channelTurnStartedAt;
6495
+ if (!channelConn?.lifecycle)
6496
+ return;
6497
+ channelLifecycleStarted = true;
6498
+ if (sessionChannelBinding?.connectorId === channelConn.id) {
6499
+ sessionChannelBinding = { ...sessionChannelBinding, working: true };
6500
+ }
6501
+ await channelLifecycle.report(channelConn, "working", {
6502
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
6503
+ firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
6504
+ ...(lastUserMessage ? { prompt: lastUserMessage } : {}),
6505
+ });
6506
+ };
6507
+ if (channelTurnRan)
6508
+ await startChannelLifecycle();
4984
6509
  // Actions are not turns. They use a different span name
4985
6510
  // and don't carry a turn.number. Branched on at `isAction`.
4986
6511
  const isAction = currentWirePayload.trigger === "action";
@@ -5036,6 +6561,10 @@ function chatAgent(options) {
5036
6561
  // (incl. parsed clientData) exists, so every toModelMessages
5037
6562
  // call this turn can re-apply tool `toModelOutput`.
5038
6563
  await resolveTurnTools();
6564
+ // The channel surface for this turn, handed to run() and reachable from a tool's
6565
+ // execute through chat.surface(), so a connector can ship tools the model drives.
6566
+ const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
6567
+ locals_js_1.locals.set(chatChannelSurfaceKey, channelSurface);
5039
6568
  // Per-turn stop controller (reset each turn)
5040
6569
  const stopController = new AbortController();
5041
6570
  currentStopController = stopController;
@@ -5057,12 +6586,19 @@ function chatAgent(options) {
5057
6586
  ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
5058
6587
  const msg = record.data
5059
6588
  .payload;
6589
+ const wireChannel = msg
6590
+ .channelEvent;
6591
+ const steeredChannel = wireChannel != null
6592
+ ? channelSteeringMessage(channels, wireChannel, msg.messageId)
6593
+ : undefined;
6594
+ if (wireChannel != null && !steeredChannel)
6595
+ return;
5060
6596
  // Slim wire: at most one delta message per record. The
5061
6597
  // pendingMessages handler reads `msg.message` directly
5062
6598
  // instead of slicing an array — a wire record arrives
5063
6599
  // with the new user message in `.message`, or no message
5064
6600
  // at all (regenerate / preload / close / handover-prepare).
5065
- const lastUIMessage = msg.message;
6601
+ const lastUIMessage = (steeredChannel?.message ?? msg.message);
5066
6602
  if (lastUIMessage) {
5067
6603
  if (pmConfig.onReceived) {
5068
6604
  try {
@@ -5083,7 +6619,10 @@ function chatAgent(options) {
5083
6619
  queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
5084
6620
  return;
5085
6621
  }
5086
- const modelMsgs = await toModelMessages([lastUIMessage]);
6622
+ const converted = await toModelMessages([lastUIMessage]);
6623
+ const modelMsgs = steeredChannel && channelDownloader
6624
+ ? await resolveChannelFiles(converted, channelDownloader)
6625
+ : converted;
5087
6626
  queue.push({
5088
6627
  uiMessage: lastUIMessage,
5089
6628
  modelMessages: modelMsgs,
@@ -5098,6 +6637,52 @@ function chatAgent(options) {
5098
6637
  })
5099
6638
  : undefined;
5100
6639
  turnMsgSub = msgSub;
6640
+ /**
6641
+ * Channel deliveries while this turn runs. A delivery the connector maps to `stop`
6642
+ * aborts this turn through the same controller the stop input uses, and is taken
6643
+ * off the route so it does not also run as a turn. Any other message is
6644
+ * acknowledged through the connector's `received` as soon as it arrives, and left
6645
+ * queued for steering or the next turn.
6646
+ */
6647
+ const controlSub = channels?.some((c) => c.onControl || c.received)
6648
+ ? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
6649
+ const msg = record.data
6650
+ .payload;
6651
+ const ce = msg.channelEvent;
6652
+ if (!ce)
6653
+ return;
6654
+ const conn = channels?.find((c) => c.id === ce.connectorId);
6655
+ if (!conn)
6656
+ return;
6657
+ let control = null;
6658
+ try {
6659
+ control = conn.onControl?.(ce.event) ?? null;
6660
+ }
6661
+ catch (controlError) {
6662
+ v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
6663
+ error: controlError,
6664
+ });
6665
+ }
6666
+ if (!control) {
6667
+ acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
6668
+ ? sessionChannelBinding.handle
6669
+ : undefined);
6670
+ return;
6671
+ }
6672
+ if (control.kind !== "stop")
6673
+ return;
6674
+ const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
6675
+ if (!earlierTurnStop && !stopController.signal.aborted) {
6676
+ stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
6677
+ }
6678
+ // Observers are offered a record before it is queued, so the take has to
6679
+ // wait a tick or it finds nothing and the stop runs again as a turn.
6680
+ void Promise.resolve().then(() => {
6681
+ chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
6682
+ });
6683
+ })
6684
+ : undefined;
6685
+ turnControlSub = controlSub;
5101
6686
  // Track new messages for this turn (user input + assistant response).
5102
6687
  const turnNewModelMessages = [];
5103
6688
  const turnNewUIMessages = [];
@@ -5127,7 +6712,8 @@ function chatAgent(options) {
5127
6712
  let actionChangedHistory = false;
5128
6713
  if (isAction) {
5129
6714
  // Parse and validate the action payload
5130
- const parsedAction = parseAction
6715
+ const isWebhookAction = currentWirePayload.actionSource === "webhook";
6716
+ const parsedAction = parseAction && !isWebhookAction
5131
6717
  ? await parseAction(currentWirePayload.action)
5132
6718
  : currentWirePayload.action;
5133
6719
  // Hydrate messages from backend if configured
@@ -5168,6 +6754,7 @@ function chatAgent(options) {
5168
6754
  clientData,
5169
6755
  uiMessages: accumulatedUIMessages,
5170
6756
  messages: accumulatedMessages,
6757
+ ...(actionChannel ? { channel: actionChannel } : {}),
5171
6758
  });
5172
6759
  }, {
5173
6760
  attributes: {
@@ -5202,7 +6789,11 @@ function chatAgent(options) {
5202
6789
  // snapshot + `session.out` replay (or `hydrateMessages`,
5203
6790
  // which also fires per-turn below). Per-turn handling is
5204
6791
  // therefore a delta merge, not a full-history reset.
5205
- if (currentWirePayload.trigger !== "action") {
6792
+ if (currentWirePayload.trigger !== "action" &&
6793
+ !droppedStaleInteraction &&
6794
+ !droppedEmptyMessage &&
6795
+ !droppedUnknownConnector &&
6796
+ !droppedIdleControl) {
5206
6797
  let cleanedUIMessages = cleanedIncomingMessages;
5207
6798
  // Turn-0 head-start with hydrateMessages: the boot seeding from
5208
6799
  // `payload.headStartMessages` is non-hydrate-only, so ship the
@@ -5454,17 +7045,41 @@ function chatAgent(options) {
5454
7045
  // calling the model would prefill its own last reply. Keyed on
5455
7046
  // the model tail, so a `tool`-terminated chain (a merged tool
5456
7047
  // approval) still runs.
5457
- const isNoOpTurn = !isAction &&
5458
- !splicedHandoverPartial &&
5459
- currentWirePayload.trigger === "submit-message" &&
5460
- turnNewUIMessages.length === 0 &&
5461
- accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
7048
+ // A channel delivery the run decided not to turn into a turn: a stale
7049
+ // interaction callback (no pending tool call matched) or an event for
7050
+ // a connector this agent does not list. Nothing to answer either way.
7051
+ const isDroppedChannelDelivery = droppedStaleInteraction ||
7052
+ droppedEmptyMessage ||
7053
+ droppedUnknownConnector ||
7054
+ droppedIdleControl;
7055
+ // A resolved interaction is never a no-op: the assistant may already have text after
7056
+ // the pending call, so the lane ends on an assistant message, but the model has not
7057
+ // seen the decision yet.
7058
+ const isNoOpTurn = isDroppedChannelDelivery ||
7059
+ (!isAction &&
7060
+ !splicedHandoverPartial &&
7061
+ !channelInteractionResolved &&
7062
+ currentWirePayload.trigger === "submit-message" &&
7063
+ turnNewUIMessages.length === 0 &&
7064
+ accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
5462
7065
  if (isNoOpTurn) {
5463
7066
  msgSub?.off();
5464
- v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
5465
- chatId: currentWirePayload.chatId,
5466
- messageId: currentWirePayload.messageId,
5467
- });
7067
+ turnControlSub?.off();
7068
+ if (channelTurnRan)
7069
+ lastChannelTurnEndedAt = Date.now();
7070
+ clearChannelWorking();
7071
+ if (channelLifecycleStarted) {
7072
+ await channelLifecycle.report(channelConn, "idle", {
7073
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7074
+ firstTurn: false,
7075
+ });
7076
+ }
7077
+ if (!isDroppedChannelDelivery) {
7078
+ v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
7079
+ chatId: currentWirePayload.chatId,
7080
+ messageId: currentWirePayload.messageId,
7081
+ });
7082
+ }
5468
7083
  settleRecoveredTurn(currentWirePayload);
5469
7084
  await writeTurnCompleteChunk(currentWirePayload.chatId);
5470
7085
  // Not a turn — don't consume an iteration.
@@ -5480,6 +7095,24 @@ function chatAgent(options) {
5480
7095
  // sees the same `turn` value — actions don't count.
5481
7096
  if (isAction) {
5482
7097
  if (isActionTurn(actionResult)) {
7098
+ if (actionResult.message !== undefined) {
7099
+ const asked = (typeof actionResult.message === "string"
7100
+ ? {
7101
+ id: (0, ai_runtime_js_1.generateId)(),
7102
+ role: "user",
7103
+ parts: [{ type: "text", text: actionResult.message }],
7104
+ }
7105
+ : actionResult.message.id
7106
+ ? actionResult.message
7107
+ : { ...actionResult.message, id: (0, ai_runtime_js_1.generateId)() });
7108
+ accumulatedUIMessages = [...accumulatedUIMessages, asked];
7109
+ accumulatedMessages = [
7110
+ ...accumulatedMessages,
7111
+ ...(await toModelMessages([asked])),
7112
+ ];
7113
+ locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
7114
+ actionChangedHistory = true;
7115
+ }
5483
7116
  // Persist the edit before the turn starts, so a turn that is
5484
7117
  // cancelled or runs out of memory continues from the edited
5485
7118
  // history rather than from the snapshot the edit replaced.
@@ -5488,6 +7121,11 @@ function chatAgent(options) {
5488
7121
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5489
7122
  }
5490
7123
  actionTurn = true;
7124
+ turnIsActionTurn = true;
7125
+ if (boundTurn && channelConn) {
7126
+ channelTurnRan = true;
7127
+ await startChannelLifecycle();
7128
+ }
5491
7129
  }
5492
7130
  else if (actionResult !== undefined) {
5493
7131
  throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
@@ -5496,6 +7134,7 @@ function chatAgent(options) {
5496
7134
  }
5497
7135
  else {
5498
7136
  msgSub?.off();
7137
+ turnControlSub?.off();
5499
7138
  if (actionChangedHistory) {
5500
7139
  await writeSnapshotOutsideTurn("action", { turn, clientData });
5501
7140
  }
@@ -5697,12 +7336,87 @@ function chatAgent(options) {
5697
7336
  }
5698
7337
  else {
5699
7338
  const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
7339
+ const runMessages = channelDownloader
7340
+ ? await resolveChannelFiles(preparedMessages, channelDownloader)
7341
+ : preparedMessages;
7342
+ // Channel observers for this turn are built before run(). The progress
7343
+ // observer and the native reply stream are shared with a manual chat.pipe()
7344
+ // inside run() through the turn tap and stay open across several pipes; the
7345
+ // ack stream editor only rides the automatic pipe, because a manual pipe
7346
+ // captures no response and posts no final reply that would supersede a
7347
+ // partial edit. The finally of this turn stops all of them and clears the tap;
7348
+ // the turn boundary ends the native stream.
7349
+ if (channelConn) {
7350
+ const observers = [];
7351
+ const tapObservers = [];
7352
+ if (channelConn.delivery === "stream" &&
7353
+ channelConn.stream &&
7354
+ (wireChannelEvent || channelHandle)) {
7355
+ const streamConnectorId = channelConn.id;
7356
+ const opened = (ref) => {
7357
+ if (sessionChannelBinding?.connectorId !== streamConnectorId)
7358
+ return;
7359
+ sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
7360
+ if (hydrateMessages)
7361
+ return;
7362
+ void saveTranscript({
7363
+ reason: "turn-start",
7364
+ messages: accumulatedUIMessages,
7365
+ turn,
7366
+ trigger: storageTrigger(currentWirePayload.trigger),
7367
+ clientData,
7368
+ lastOutEventId: lastSnapshotOutEventId,
7369
+ skipIfUnchanged: true,
7370
+ carryInCursor: true,
7371
+ }).catch((error) => {
7372
+ v3_1.logger.warn("chat.agent: saving the open reply stream failed", {
7373
+ error: error instanceof Error ? error.message : String(error),
7374
+ });
7375
+ });
7376
+ };
7377
+ try {
7378
+ channelReplyStream =
7379
+ channelConn.stream({
7380
+ ...channelTurnCtx(wireChannelEvent, channelHandle),
7381
+ opened,
7382
+ }) ?? undefined;
7383
+ }
7384
+ catch (streamError) {
7385
+ v3_1.logger.warn("chat.agent: channel stream() threw; posting the reply", {
7386
+ error: streamError,
7387
+ });
7388
+ }
7389
+ }
7390
+ if (channelReplyStream) {
7391
+ const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
7392
+ observers.push(replyObserver);
7393
+ tapObservers.push(replyObserver);
7394
+ }
7395
+ else if (wireChannelEvent &&
7396
+ channelConn.send &&
7397
+ channelConn.delivery === "stream" &&
7398
+ channelAckRef) {
7399
+ observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
7400
+ }
7401
+ if (channelConn.progress && (wireChannelEvent || channelHandle)) {
7402
+ const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
7403
+ observers.push(progressObserver);
7404
+ tapObservers.push(progressObserver);
7405
+ }
7406
+ if (tapObservers.length > 0) {
7407
+ const tapObserver = composeChannelObservers(tapObservers);
7408
+ locals_js_1.locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
7409
+ }
7410
+ if (observers.length > 0) {
7411
+ channelStreamEditor = composeChannelObservers(observers);
7412
+ }
7413
+ }
5700
7414
  runResult = await userRun({
5701
7415
  ...restWire,
5702
7416
  // A turn requested by chat.turn() is not the action itself:
5703
7417
  // a run() that short-circuits on "action" must still answer.
5704
7418
  ...(actionTurn ? { trigger: "action-turn" } : {}),
5705
- messages: preparedMessages,
7419
+ messages: runMessages,
5706
7420
  clientData,
5707
7421
  continuation,
5708
7422
  previousRunId,
@@ -5715,6 +7429,7 @@ function chatAgent(options) {
5715
7429
  cancelSignal,
5716
7430
  stopSignal,
5717
7431
  streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
7432
+ channel: channelSurface,
5718
7433
  });
5719
7434
  }
5720
7435
  // Auto-pipe if the run function returned a StreamTextResult or similar,
@@ -5748,7 +7463,13 @@ function chatAgent(options) {
5748
7463
  resolveOnFinish();
5749
7464
  },
5750
7465
  });
5751
- await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
7466
+ // The automatic pipe feeds every observer (progress and the ack stream editor)
7467
+ // itself; the manual tap is cleared so pipeChat does not feed progress twice.
7468
+ locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
7469
+ const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
7470
+ ? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
7471
+ : uiStream;
7472
+ await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
5752
7473
  signal: combinedSignal,
5753
7474
  spanName: "stream response",
5754
7475
  }, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
@@ -5771,6 +7492,9 @@ function chatAgent(options) {
5771
7492
  }
5772
7493
  finally {
5773
7494
  msgSub?.off();
7495
+ turnControlSub?.off();
7496
+ locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
7497
+ await channelStreamEditor?.stop();
5774
7498
  }
5775
7499
  // Wait for onFinish to fire — on abort this may resolve slightly
5776
7500
  // after pipeChat, since the stream's cancel() handler is async.
@@ -6014,6 +7738,16 @@ function chatAgent(options) {
6014
7738
  if (capturedResponseMessage) {
6015
7739
  responseCommitted = true;
6016
7740
  capturedPartialResponse = capturedResponseMessage;
7741
+ const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
7742
+ channelStreamErrorText = errorChunk?.errorText;
7743
+ channelTurnText = turnBufferedChunks
7744
+ .map((chunk) => {
7745
+ const c = chunk;
7746
+ return c.type === "text-delta" && typeof c.delta === "string"
7747
+ ? c.delta
7748
+ : "";
7749
+ })
7750
+ .join("");
6017
7751
  turnBufferedChunks.length = 0;
6018
7752
  }
6019
7753
  if (runSignal.aborted)
@@ -6233,8 +7967,186 @@ function chatAgent(options) {
6233
7967
  }
6234
7968
  }
6235
7969
  settleRecoveredTurn(currentWirePayload);
7970
+ openChannelEgressLatch(currentWirePayload.chatId);
6236
7971
  // Write turn-complete control chunk — closes the frontend stream.
6237
7972
  const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
7973
+ // Tool calls that have not had controls posted yet; a resume with the rest still
7974
+ // pending must not repost them or hide the answer text behind them.
7975
+ const pendingToolCalls = (wireChannelEvent || boundTurn) &&
7976
+ channelConn?.send &&
7977
+ turnCompleteEvent.responseMessage
7978
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
7979
+ : [];
7980
+ const renderControls = !!((wireChannelEvent || boundTurn) &&
7981
+ pendingToolCalls.length > 0 &&
7982
+ channelConn?.renderInteraction);
7983
+ let channelReplyDelivered = false;
7984
+ if (channelReplyStream) {
7985
+ const replyStream = channelReplyStream;
7986
+ channelReplyStream = undefined;
7987
+ try {
7988
+ const ended = await replyStream.end({
7989
+ stopped: turnCompleteEvent.stopped,
7990
+ pending: renderControls,
7991
+ ...(channelStreamErrorText ? { errorText: channelStreamErrorText } : {}),
7992
+ });
7993
+ channelReplyDelivered = ended.delivered;
7994
+ }
7995
+ catch (streamError) {
7996
+ v3_1.logger.warn("chat.agent: channel reply stream end failed", {
7997
+ error: streamError,
7998
+ });
7999
+ }
8000
+ finally {
8001
+ clearOpenReplyStream();
8002
+ }
8003
+ }
8004
+ // Channel egress: map the turn's reply via outbound() and send it, editing the ack
8005
+ // placeholder posted at turn start (previousRef). A failed send is retried; one that
8006
+ // never lands is logged and the turn gets the error reaction. A manual-pipe turn has
8007
+ // no responseMessage, so it opts out.
8008
+ const finalChannelConn = channelConn;
8009
+ if ((wireChannelEvent || boundTurn) &&
8010
+ finalChannelConn?.send &&
8011
+ turnCompleteEvent.responseMessage) {
8012
+ if (renderControls) {
8013
+ for (const call of pendingToolCalls) {
8014
+ channelRenderedToolCallIds.add(call.toolCallId);
8015
+ }
8016
+ }
8017
+ const replyText = channelTurnText && channelTurnText.trim().length > 0
8018
+ ? channelTurnText
8019
+ : channelReplyText(turnCompleteEvent.responseMessage);
8020
+ // A model or stream error surfaces as an `error` chunk and an empty reply, not as a
8021
+ // thrown turn. The mapper gets it on this one call, with the error text standing in
8022
+ // for an empty reply, so the placeholder never sits there as if the agent were
8023
+ // still working.
8024
+ const finalReply = {
8025
+ // Prefer what this turn streamed; a resumed message carries earlier turns' text too.
8026
+ text: channelStreamErrorText && replyText.trim().length === 0
8027
+ ? channelStreamErrorText
8028
+ : replyText,
8029
+ message: turnCompleteEvent.responseMessage,
8030
+ final: true,
8031
+ stopped: turnCompleteEvent.stopped,
8032
+ ...(renderControls ? { pending: true } : {}),
8033
+ ...(channelStreamErrorText
8034
+ ? { error: new Error(channelStreamErrorText) }
8035
+ : {}),
8036
+ };
8037
+ let outboundMessage = null;
8038
+ if (!channelReplyDelivered) {
8039
+ try {
8040
+ outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
8041
+ }
8042
+ catch (outboundError) {
8043
+ v3_1.logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
8044
+ error: outboundError,
8045
+ });
8046
+ outboundMessage = defaultChannelOutbound(finalReply) ?? {
8047
+ text: "An unexpected error occurred",
8048
+ };
8049
+ }
8050
+ }
8051
+ const sendFinal = async (message) => {
8052
+ for (let attempt = 1;; attempt++) {
8053
+ try {
8054
+ const sent = await finalChannelConn.send(message, {
8055
+ event: wireChannelEvent?.event,
8056
+ handle: channelHandle,
8057
+ deliveryId: wireChannelEvent?.deliveryId,
8058
+ previousRef: channelAckRef,
8059
+ mode: finalChannelConn.delivery,
8060
+ final: true,
8061
+ bound: boundTurn,
8062
+ });
8063
+ channelFinalAnswerPosted = true;
8064
+ return sent ?? {};
8065
+ }
8066
+ catch (egressError) {
8067
+ if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
8068
+ channelFinalSendFailed = true;
8069
+ v3_1.logger.error("chat.agent: channel egress send failed", {
8070
+ error: egressError,
8071
+ attempts: attempt,
8072
+ });
8073
+ return undefined;
8074
+ }
8075
+ v3_1.logger.warn("chat.agent: channel egress send failed; retrying", {
8076
+ error: egressError,
8077
+ attempt,
8078
+ });
8079
+ await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
8080
+ }
8081
+ }
8082
+ };
8083
+ // A turn that pauses on a human decision often explains itself first (a summary,
8084
+ // the steps so far) and then calls the tool. That text is the answer so far: post it
8085
+ // as the placeholder edit, and post the controls as their own message after it.
8086
+ if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
8087
+ channelAckRef = undefined;
8088
+ }
8089
+ const channelMessage = renderControls
8090
+ ? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
8091
+ ? {
8092
+ event: wireChannelEvent.event,
8093
+ deliveryId: wireChannelEvent.deliveryId,
8094
+ }
8095
+ : {})
8096
+ : outboundMessage;
8097
+ if (channelMessage) {
8098
+ const controlsRef = (await sendFinal(channelMessage))?.ref;
8099
+ if (renderControls &&
8100
+ controlsRef &&
8101
+ sessionChannelBinding?.connectorId === finalChannelConn.id) {
8102
+ sessionChannelBinding = {
8103
+ ...sessionChannelBinding,
8104
+ controls: {
8105
+ ...sessionChannelBinding.controls,
8106
+ ...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
8107
+ },
8108
+ };
8109
+ }
8110
+ }
8111
+ }
8112
+ if (channelTurnRan)
8113
+ lastChannelTurnEndedAt = Date.now();
8114
+ clearChannelWorking();
8115
+ if (channelLifecycleStarted) {
8116
+ const stillPending = turnCompleteEvent.responseMessage
8117
+ ? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
8118
+ : false;
8119
+ await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey)
8120
+ ? "closed"
8121
+ : stillPending
8122
+ ? "waiting"
8123
+ : "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
8124
+ }
8125
+ // Lifecycle reaction: turn done. Remove "working", mark "done".
8126
+ if (wireChannelEvent && channelConn?.react) {
8127
+ try {
8128
+ if (channelWorkingReaction) {
8129
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8130
+ name: channelWorkingReaction,
8131
+ remove: true,
8132
+ });
8133
+ }
8134
+ const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
8135
+ ? channelConn.reactions?.error
8136
+ : channelConn.reactions?.done, wireChannelEvent.event);
8137
+ if (outcomeReaction) {
8138
+ await applyChannelReaction(channelConn, wireChannelEvent, {
8139
+ name: outcomeReaction,
8140
+ });
8141
+ }
8142
+ }
8143
+ catch (reactionError) {
8144
+ v3_1.logger.warn("chat.agent: channel done reaction failed", {
8145
+ error: reactionError,
8146
+ });
8147
+ }
8148
+ }
8149
+ settleChannelEgressLatch(currentWirePayload.chatId);
6238
8150
  // Fire onTurnComplete — stream is closed, use for persistence.
6239
8151
  if (onTurnComplete) {
6240
8152
  await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
@@ -6352,6 +8264,7 @@ function chatAgent(options) {
6352
8264
  // chat.endRun() — same exit, no upgrade semantics.
6353
8265
  if (locals_js_1.locals.get(chatCloseRequestedKey)) {
6354
8266
  await performChatClose();
8267
+ await reportChannelClosed();
6355
8268
  return "exit";
6356
8269
  }
6357
8270
  if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
@@ -6423,6 +8336,7 @@ function chatAgent(options) {
6423
8336
  currentWirePayload = next.output;
6424
8337
  // Close signal — exit the loop gracefully
6425
8338
  if (currentWirePayload.trigger === "close") {
8339
+ await reportChannelClosed();
6426
8340
  return "exit";
6427
8341
  }
6428
8342
  return "continue";
@@ -6440,12 +8354,16 @@ function chatAgent(options) {
6440
8354
  // Detach the turn's message handler first — left attached it would
6441
8355
  // eat the very message the wait below is waiting for.
6442
8356
  turnMsgSub?.off();
8357
+ turnControlSub?.off();
6443
8358
  if (turnError instanceof Error &&
6444
8359
  turnError.name === "AbortError" &&
6445
8360
  runSignal.aborted) {
8361
+ settleChannelEgressLatch(currentWirePayload.chatId);
8362
+ channelLifecycle.dispose();
6446
8363
  // Full run cancellation — exit immediately
6447
8364
  throw turnError;
6448
8365
  }
8366
+ settleChannelEgressLatch(currentWirePayload.chatId);
6449
8367
  // OOM errors must escape the turn loop so the task runtime can
6450
8368
  // honor `retry.outOfMemory.machine` (set on chat.agent via
6451
8369
  // `oomMachine`). Catching them here would keep the dead worker
@@ -6456,6 +8374,109 @@ function chatAgent(options) {
6456
8374
  if (turnError instanceof v3_1.OutOfMemoryError) {
6457
8375
  throw turnError;
6458
8376
  }
8377
+ if (channelWireEvent && channelConn?.react) {
8378
+ try {
8379
+ if (channelWorkingReaction) {
8380
+ await applyChannelReaction(channelConn, channelWireEvent, {
8381
+ name: channelWorkingReaction,
8382
+ remove: true,
8383
+ });
8384
+ }
8385
+ const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
8386
+ if (errorReaction) {
8387
+ await applyChannelReaction(channelConn, channelWireEvent, {
8388
+ name: errorReaction,
8389
+ });
8390
+ }
8391
+ }
8392
+ catch (reactionError) {
8393
+ v3_1.logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
8394
+ }
8395
+ }
8396
+ let channelErrorText = "An unexpected error occurred";
8397
+ if ((channelWireEvent || boundTurn) && channelConn) {
8398
+ const sanitizeChannelError = resolveUIMessageStreamOptions().onError;
8399
+ try {
8400
+ channelErrorText = sanitizeChannelError
8401
+ ? sanitizeChannelError(turnError)
8402
+ : turnError instanceof Error
8403
+ ? turnError.message
8404
+ : channelErrorText;
8405
+ }
8406
+ catch { }
8407
+ }
8408
+ let errorStreamDelivered = false;
8409
+ if (channelReplyStream) {
8410
+ const replyStream = channelReplyStream;
8411
+ channelReplyStream = undefined;
8412
+ try {
8413
+ const ended = await replyStream.end({
8414
+ stopped: false,
8415
+ pending: false,
8416
+ errorText: channelErrorText,
8417
+ });
8418
+ errorStreamDelivered = ended.delivered;
8419
+ }
8420
+ catch (streamError) {
8421
+ v3_1.logger.warn("chat.agent: channel reply stream end failed", {
8422
+ error: streamError,
8423
+ });
8424
+ }
8425
+ finally {
8426
+ clearOpenReplyStream();
8427
+ }
8428
+ }
8429
+ if ((channelWireEvent || boundTurn) && channelConn?.send) {
8430
+ const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
8431
+ const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
8432
+ let errorMessage = null;
8433
+ try {
8434
+ errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
8435
+ text: channelErrorText,
8436
+ message: {
8437
+ id: channelErrorMessageId,
8438
+ role: "assistant",
8439
+ parts: [{ type: "text", text: channelErrorText }],
8440
+ },
8441
+ final: true,
8442
+ stopped: false,
8443
+ error: turnError,
8444
+ });
8445
+ }
8446
+ catch (outboundError) {
8447
+ v3_1.logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
8448
+ error: outboundError,
8449
+ });
8450
+ errorMessage = { text: channelErrorText };
8451
+ }
8452
+ if (errorMessage && !errorStreamDelivered) {
8453
+ try {
8454
+ await channelConn.send(errorMessage, {
8455
+ event: channelWireEvent?.event,
8456
+ handle: channelHandle,
8457
+ deliveryId: channelWireEvent?.deliveryId,
8458
+ previousRef: errorPreviousRef,
8459
+ mode: channelConn.delivery,
8460
+ final: true,
8461
+ bound: boundTurn,
8462
+ });
8463
+ }
8464
+ catch (egressError) {
8465
+ v3_1.logger.warn("chat.agent: channel error egress send failed", {
8466
+ error: egressError,
8467
+ });
8468
+ }
8469
+ }
8470
+ }
8471
+ if (channelTurnRan)
8472
+ lastChannelTurnEndedAt = Date.now();
8473
+ clearChannelWorking();
8474
+ if (channelLifecycleStarted) {
8475
+ await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
8476
+ ...channelTurnCtx(channelWireEvent, channelHandle),
8477
+ firstTurn: false,
8478
+ });
8479
+ }
6459
8480
  let errorTurnCompleteResult;
6460
8481
  try {
6461
8482
  await withChatWriter(async (writer) => {
@@ -6712,6 +8733,7 @@ function chatAgent(options) {
6712
8733
  .get(chatManagedResponseKey)
6713
8734
  ?.close()
6714
8735
  .catch(() => { });
8736
+ turnControlSub?.off();
6715
8737
  }
6716
8738
  }
6717
8739
  }
@@ -6735,6 +8757,45 @@ function chatAgent(options) {
6735
8757
  schema: clientDataSchema,
6736
8758
  });
6737
8759
  }
8760
+ // Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
8761
+ // The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
8762
+ if (events) {
8763
+ for (const wh of events) {
8764
+ v3_1.resourceCatalog.markSessionWebhookClaimed(wh.id);
8765
+ v3_1.resourceCatalog.registerWebhookSubscriber({
8766
+ endpointId: wh.endpointId,
8767
+ target: {
8768
+ type: "session",
8769
+ id: `${options.id}:${wh.id}`,
8770
+ taskIdentifier: options.id,
8771
+ keyTemplate: wh.key,
8772
+ actionType: wh.type,
8773
+ deliverAs: "action",
8774
+ ...(wh.filter ? { filter: wh.filter } : {}),
8775
+ },
8776
+ });
8777
+ }
8778
+ }
8779
+ // Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
8780
+ // verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
8781
+ if (channels) {
8782
+ for (const ch of channels) {
8783
+ v3_1.resourceCatalog.markSessionWebhookClaimed(ch.id);
8784
+ v3_1.resourceCatalog.registerWebhookSubscriber({
8785
+ endpointId: ch.endpointId,
8786
+ target: {
8787
+ type: "session",
8788
+ id: `${options.id}:${ch.id}`,
8789
+ taskIdentifier: options.id,
8790
+ keyTemplate: ch.key,
8791
+ connectorId: ch.id,
8792
+ deliverAs: "message",
8793
+ ...(ch.filter ? { filter: ch.filter } : {}),
8794
+ ...(ch.startOn ? { startOn: ch.startOn } : {}),
8795
+ },
8796
+ });
8797
+ }
8798
+ }
6738
8799
  return task;
6739
8800
  }
6740
8801
  function composeHooks(builderHook, taskHook) {
@@ -7030,6 +9091,22 @@ function resolveUIMessageStreamOptions() {
7030
9091
  // ---------------------------------------------------------------------------
7031
9092
  // Stop detection
7032
9093
  // ---------------------------------------------------------------------------
9094
+ /**
9095
+ * The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
9096
+ * tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
9097
+ * `surface()`. Pass the connector's surface type to get its helpers typed:
9098
+ *
9099
+ * @example
9100
+ * ```ts
9101
+ * execute: async ({ steps }) => {
9102
+ * const linear = chat.surface<LinearRunSurface>();
9103
+ * if (linear) await linear.plan(steps);
9104
+ * }
9105
+ * ```
9106
+ */
9107
+ function getChannelSurface() {
9108
+ return locals_js_1.locals.get(chatChannelSurfaceKey);
9109
+ }
7033
9110
  /**
7034
9111
  * Check whether the user stopped generation during the current turn.
7035
9112
  *
@@ -9027,6 +11104,10 @@ function createChatLoadTranscriptAction(storage, options) {
9027
11104
  exports.chat = {
9028
11105
  /** Create a chat agent. See {@link chatAgent}. */
9029
11106
  agent: chatAgent,
11107
+ /** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
11108
+ event: webhooks_js_1.chatEvent,
11109
+ /** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
11110
+ channels: chatChannels,
9030
11111
  /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
9031
11112
  customAgent: chatCustomAgent,
9032
11113
  /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
@@ -9045,6 +11126,8 @@ exports.chat = {
9045
11126
  local: chatLocal,
9046
11127
  /** Create a public access token for a chat task. See {@link createChatAccessToken}. */
9047
11128
  createAccessToken: createChatAccessToken,
11129
+ /** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
11130
+ createWatchToken: createChatWatchToken,
9048
11131
  /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
9049
11132
  setTurnTimeout,
9050
11133
  /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
@@ -9055,6 +11138,8 @@ exports.chat = {
9055
11138
  setUIMessageStreamOptions,
9056
11139
  /** Check if the current turn was stopped by the user. See {@link isStopped}. */
9057
11140
  isStopped,
11141
+ /** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
11142
+ surface: getChannelSurface,
9058
11143
  /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
9059
11144
  requestUpgrade,
9060
11145
  /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */