@copilotkit/channels-core 0.5.0 → 0.5.1-canary.1785633429

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 (82) hide show
  1. package/README.md +35 -14
  2. package/dist/action-registry.d.ts +15 -3
  3. package/dist/action-registry.d.ts.map +1 -1
  4. package/dist/action-registry.js +61 -9
  5. package/dist/action-registry.test.js +107 -2
  6. package/dist/action-store.d.ts +23 -0
  7. package/dist/action-store.d.ts.map +1 -1
  8. package/dist/action-store.js +11 -0
  9. package/dist/canonical-run-loop.test.js +6 -1
  10. package/dist/channel-identity.test.d.ts +2 -0
  11. package/dist/channel-identity.test.d.ts.map +1 -0
  12. package/dist/channel-identity.test.js +297 -0
  13. package/dist/channel-memory.test.d.ts +2 -0
  14. package/dist/channel-memory.test.d.ts.map +1 -0
  15. package/dist/channel-memory.test.js +199 -0
  16. package/dist/commands.d.ts +5 -3
  17. package/dist/commands.d.ts.map +1 -1
  18. package/dist/create-channel.d.ts +30 -56
  19. package/dist/create-channel.d.ts.map +1 -1
  20. package/dist/create-channel.foundations.test.js +30 -7
  21. package/dist/create-channel.js +209 -72
  22. package/dist/create-channel.test.js +293 -66
  23. package/dist/hitl-continuation.test.d.ts +2 -0
  24. package/dist/hitl-continuation.test.d.ts.map +1 -0
  25. package/dist/hitl-continuation.test.js +393 -0
  26. package/dist/identity.d.ts +51 -0
  27. package/dist/identity.d.ts.map +1 -0
  28. package/dist/identity.js +56 -0
  29. package/dist/identity.test.d.ts +2 -0
  30. package/dist/identity.test.d.ts.map +1 -0
  31. package/dist/identity.test.js +109 -0
  32. package/dist/index.d.ts +10 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -1
  35. package/dist/managed-v1-await-choice-guard.test.js +4 -1
  36. package/dist/memory.d.ts +23 -0
  37. package/dist/memory.d.ts.map +1 -0
  38. package/dist/memory.js +25 -0
  39. package/dist/modal-submit.test.js +21 -6
  40. package/dist/open-modal.test.js +16 -4
  41. package/dist/platform-adapter.d.ts +29 -6
  42. package/dist/platform-adapter.d.ts.map +1 -1
  43. package/dist/platform-adapter.test.js +2 -0
  44. package/dist/reactions.test.js +49 -11
  45. package/dist/run-loop.test.js +30 -5
  46. package/dist/sanitize-agent-events.test.js +2 -0
  47. package/dist/source-platform.test.js +24 -9
  48. package/dist/state/kv-action-store.d.ts.map +1 -1
  49. package/dist/state/kv-action-store.js +1 -0
  50. package/dist/state/kv-action-store.test.js +11 -0
  51. package/dist/state/memory-store.d.ts +1 -0
  52. package/dist/state/memory-store.d.ts.map +1 -1
  53. package/dist/state/memory-store.js +9 -0
  54. package/dist/state/state-store.d.ts +2 -0
  55. package/dist/state/state-store.d.ts.map +1 -1
  56. package/dist/telemetry/create-channel-telemetry.test.js +4 -2
  57. package/dist/telemetry/e2e-telemetry.test.js +1 -0
  58. package/dist/telemetry/install-id.test.js +5 -0
  59. package/dist/testing/fake-adapter.d.ts +26 -9
  60. package/dist/testing/fake-adapter.d.ts.map +1 -1
  61. package/dist/testing/fake-adapter.js +65 -21
  62. package/dist/testing/state-store-conformance.d.ts.map +1 -1
  63. package/dist/testing/state-store-conformance.js +16 -0
  64. package/dist/thread-capabilities.test.js +29 -9
  65. package/dist/thread-ephemeral.test.js +8 -5
  66. package/dist/thread-promise-contract.test.js +4 -0
  67. package/dist/thread-reactions.test.js +8 -2
  68. package/dist/thread.d.ts +50 -7
  69. package/dist/thread.d.ts.map +1 -1
  70. package/dist/thread.js +163 -17
  71. package/dist/thread.test.js +4 -0
  72. package/dist/tools.d.ts +5 -3
  73. package/dist/tools.d.ts.map +1 -1
  74. package/dist/tools.js +2 -1
  75. package/dist/transcripts.d.ts +6 -13
  76. package/dist/transcripts.d.ts.map +1 -1
  77. package/dist/transcripts.js +12 -12
  78. package/dist/transcripts.test.js +28 -29
  79. package/dist/welcome.test.d.ts +2 -0
  80. package/dist/welcome.test.d.ts.map +1 -0
  81. package/dist/welcome.test.js +53 -0
  82. package/package.json +5 -5
@@ -7,10 +7,12 @@ import { Thread } from "./thread.js";
7
7
  import { sanitizeAgentEventStream } from "./sanitize-agent-events.js";
8
8
  import { normalizeEmoji, toCanonicalEmoji, renderToIR, } from "@copilotkit/channels-ui";
9
9
  import { Transcripts } from "./transcripts.js";
10
+ import { resolveChannelUser } from "./identity.js";
10
11
  import { ChannelTelemetry } from "./telemetry/channel-telemetry.js";
11
12
  import { errorClass, normalizePlatform } from "./telemetry/sanitize-error.js";
12
13
  import { createRequire } from "node:module";
13
14
  const pkg = createRequire(import.meta.url)("../package.json");
15
+ const ADAPTER_ROLLBACK_TIMEOUT_MS = 5_000;
14
16
  function storeKind(s) {
15
17
  const n = s.constructor?.name;
16
18
  if (n === "MemoryStore")
@@ -138,8 +140,9 @@ function msgFromTurn(turn) {
138
140
  return {
139
141
  text: turn.userText,
140
142
  contentParts: turn.contentParts,
141
- user: turn.user ?? { id: "" },
142
- ref: { id: "" },
143
+ user: null,
144
+ actor: turn.actor,
145
+ ref: turn.messageRef ?? { id: "" },
143
146
  platform: turn.platform,
144
147
  operation: turn.operation,
145
148
  eventId: turn.eventId,
@@ -147,21 +150,42 @@ function msgFromTurn(turn) {
147
150
  deliveryId: turn.deliveryId,
148
151
  };
149
152
  }
153
+ function identityContextFromIngress(provider, event, trigger) {
154
+ const actor = Object.freeze({
155
+ ...(event.actor ?? { id: "", kind: "unknown" }),
156
+ });
157
+ const tenant = Object.freeze({
158
+ ...(event.identityContext?.tenant ?? { id: "" }),
159
+ });
160
+ const installation = Object.freeze({
161
+ ...(event.identityContext?.installation ?? { id: "" }),
162
+ });
163
+ const conversation = Object.freeze({
164
+ ...(event.identityContext?.conversation ?? {
165
+ id: event.conversationKey ?? "",
166
+ }),
167
+ });
168
+ const normalizedEvent = Object.freeze({
169
+ ...event.identityContext?.event,
170
+ });
171
+ return Object.freeze({
172
+ provider,
173
+ tenant,
174
+ installation,
175
+ actor,
176
+ conversation,
177
+ trigger: event.identityContext?.trigger ?? trigger,
178
+ event: normalizedEvent,
179
+ raw: event.identityContext?.raw,
180
+ ...(event.identityContext?.lookupProfile
181
+ ? { lookupProfile: event.identityContext.lookupProfile }
182
+ : {}),
183
+ });
184
+ }
150
185
  /** Resolve the provider that produced an event without changing adapter identity. */
151
186
  function ingressPlatform(adapter, event) {
152
187
  return event.platform ?? adapter.platform;
153
188
  }
154
- /**
155
- * Enforce managed Intelligence Channel exclusivity. Managed and direct
156
- * delivery are alternative modes on a Channel: Intelligence holds provider
157
- * credentials, or the runtime does, never both.
158
- */
159
- function assertExclusive(adapters) {
160
- if (adapters.some((a) => a.__intelligenceChannel) && adapters.length > 1) {
161
- throw new Error("The managed Intelligence adapter must be the only adapter on a Channel — " +
162
- "managed and direct delivery are alternative modes.");
163
- }
164
- }
165
189
  /**
166
190
  * Resolve the persistence backend at start(): an explicit `store.adapter` wins
167
191
  * (silently); otherwise an adapter-provided `stateStore` is used (warning when
@@ -179,16 +203,15 @@ function resolveBackend(explicit, adapters) {
179
203
  return providers[0]?.stateStore ?? new MemoryStore();
180
204
  }
181
205
  export function createChannel(opts) {
182
- const cfg = opts.store ?? {};
183
- if ((cfg.identity && !cfg.transcripts) ||
184
- (!cfg.identity && cfg.transcripts)) {
185
- throw new Error("createChannel: `identity` and `transcripts` must be configured together.");
206
+ if (opts.identifyUser !== "platform" &&
207
+ typeof opts.identifyUser !== "function") {
208
+ throw new Error('createChannel: `identifyUser` must be "platform" or a callback');
186
209
  }
210
+ const cfg = opts.store ?? {};
187
211
  // Adapters can be supplied up front or added later via
188
212
  // `channel.ɵruntime.addAdapter` (before `channel.ɵruntime.start()`). The
189
213
  // runtime uses the latter to attach Channel delivery.
190
214
  const adapters = [...(opts.adapters ?? [])];
191
- assertExclusive(adapters);
192
215
  let started = false;
193
216
  // Backend, transcripts, telemetry, the action registry, and component
194
217
  // registration are resolved in `ɵruntime.start()` — not at construction —
@@ -238,6 +261,7 @@ export function createChannel(opts) {
238
261
  const context = opts.context ?? [];
239
262
  const mentionHandlers = [];
240
263
  const messageHandlers = [];
264
+ const welcomeHandlers = [];
241
265
  const threadStartedHandlers = [];
242
266
  const interactionHandlers = new Map();
243
267
  const interruptHandlers = new Map();
@@ -248,6 +272,7 @@ export function createChannel(opts) {
248
272
  const modalSubmitHandlers = new Map();
249
273
  const modalCloseHandlers = new Map();
250
274
  const waiters = new Map();
275
+ let intelligenceMemoryAvailable = false;
251
276
  // Recomputed on start() so tools added via channel.tool() before start are picked up.
252
277
  let toolDescriptors = toAgentToolDescriptors([...toolMap.values()]);
253
278
  function makeThread(adapter, replyTarget, conversationKey, extras) {
@@ -269,8 +294,13 @@ export function createChannel(opts) {
269
294
  state: backend,
270
295
  stateSchema: cfg.state,
271
296
  transcripts,
272
- userKey: extras?.userKey,
273
297
  message: extras?.message,
298
+ user: extras?.user ?? null,
299
+ actor: extras?.actor ?? { id: "unknown", kind: "unknown" },
300
+ channelName: opts.name ?? adapter.platform,
301
+ threadId: adapter.getCanonicalThreadId?.(replyTarget) ?? conversationKey,
302
+ interactionActionId: extras?.interactionActionId,
303
+ intelligenceMemoryAvailable,
274
304
  telemetry,
275
305
  };
276
306
  return new Thread(deps);
@@ -312,26 +342,19 @@ export function createChannel(opts) {
312
342
  console.warn(`[channel] dedup check failed for ${platform}; processing without dedup`, err);
313
343
  }
314
344
  }
315
- // Resolve cross-platform identity key (if configured) and stamp it on
316
- // the message so handlers and transcript storage can use it. Done
317
- // BEFORE makeThread so the thread carries the userKey + message for
318
- // the transcript auto-bridge (runAgent({ transcript: true })).
319
- let userKey;
320
- if (cfg.identity) {
321
- try {
322
- const resolved = await cfg.identity({
323
- adapter: platform,
324
- author: turn.user ?? { id: "" },
325
- message: msgFromTurn(turn),
326
- });
327
- userKey = resolved ?? undefined;
328
- }
329
- catch (err) {
330
- console.warn(`[channel] identity resolution failed for ${platform}; continuing without userKey`, err);
331
- }
332
- }
333
- const message = { ...msgFromTurn(turn), userKey };
334
- const thread = makeThread(adapter, turn.replyTarget, turn.conversationKey, { platform, userKey, message });
345
+ const identityContext = identityContextFromIngress(platform, turn, "message");
346
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
347
+ const message = {
348
+ ...msgFromTurn(turn),
349
+ user,
350
+ actor: identityContext.actor,
351
+ };
352
+ const thread = makeThread(adapter, turn.replyTarget, turn.conversationKey, {
353
+ platform,
354
+ message,
355
+ user,
356
+ actor: identityContext.actor,
357
+ });
335
358
  const handlers = turn.operation.mentioned
336
359
  ? mentionHandlers.length > 0
337
360
  ? mentionHandlers
@@ -396,19 +419,27 @@ export function createChannel(opts) {
396
419
  console.warn(`[channel] dedup check failed for ${platform}; processing without dedup`, err);
397
420
  }
398
421
  }
399
- const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, { platform });
400
- const user = evt.user ?? { id: "" };
422
+ const identityContext = identityContextFromIngress(platform, evt, "interaction");
423
+ const canonicalUser = await resolveChannelUser(opts.identifyUser, identityContext);
424
+ const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, {
425
+ platform,
426
+ user: canonicalUser,
427
+ actor: identityContext.actor,
428
+ interactionActionId: evt.id,
429
+ });
401
430
  const ctx = {
402
431
  thread,
403
432
  message: {
404
433
  text: "",
405
- user,
434
+ user: canonicalUser,
435
+ actor: identityContext.actor,
406
436
  ref: evt.messageRef ?? { id: "" },
407
437
  platform,
408
438
  },
409
439
  action: { id: evt.id, value: evt.value },
410
- values: {},
411
- user,
440
+ values: evt.values ?? {},
441
+ user: canonicalUser,
442
+ actor: identityContext.actor,
412
443
  platform,
413
444
  };
414
445
  const openModal = makeOpenModal(adapter, evt.replyTarget, evt.triggerId);
@@ -458,7 +489,13 @@ export function createChannel(opts) {
458
489
  const command = commandHandlers.get(normalizeCommandName(cmd.command));
459
490
  if (!command)
460
491
  return; // unregistered command → skip
461
- const thread = makeThread(adapter, cmd.replyTarget, cmd.conversationKey, { platform });
492
+ const identityContext = identityContextFromIngress(platform, cmd, "command");
493
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
494
+ const thread = makeThread(adapter, cmd.replyTarget, cmd.conversationKey, {
495
+ platform,
496
+ user,
497
+ actor: identityContext.actor,
498
+ });
462
499
  // Resolve typed options from any structured args the surface supplied
463
500
  // (e.g. Discord); text-only surfaces (Slack) leave `options` empty and
464
501
  // the handler reads `text`.
@@ -473,7 +510,8 @@ export function createChannel(opts) {
473
510
  command: normalizeCommandName(cmd.command),
474
511
  text: cmd.text,
475
512
  options,
476
- user: cmd.user,
513
+ user,
514
+ actor: identityContext.actor,
477
515
  platform,
478
516
  };
479
517
  const openModal = makeOpenModal(adapter, cmd.replyTarget, cmd.triggerId);
@@ -481,13 +519,35 @@ export function createChannel(opts) {
481
519
  ctx.openModal = openModal;
482
520
  await command.handler(ctx);
483
521
  },
522
+ async onWelcome(evt) {
523
+ const platform = ingressPlatform(adapter, evt);
524
+ if (evt.eventId && !adapter.skipIngressDedup) {
525
+ const dupKey = `evt:${platform}:${evt.eventId}`;
526
+ try {
527
+ if (await store.dedup.seen(dupKey, cfg.dedupTtl ?? 300_000))
528
+ return;
529
+ }
530
+ catch (err) {
531
+ console.warn(`[channel] dedup check failed for ${platform}; processing welcome without dedup`, err);
532
+ }
533
+ }
534
+ const identityContext = identityContextFromIngress(platform, evt, "welcome");
535
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
536
+ const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, { platform, user, actor: identityContext.actor });
537
+ for (const h of welcomeHandlers) {
538
+ await h({ thread, user, actor: identityContext.actor, platform });
539
+ }
540
+ },
484
541
  async onThreadStarted(evt) {
485
542
  // The adapter has already applied its static defaults (greeting /
486
543
  // prompts) before emitting this, so handlers layer on top and never
487
544
  // race. Zero handlers → no-op.
488
- const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, { platform: ingressPlatform(adapter, evt) });
545
+ const platform = ingressPlatform(adapter, evt);
546
+ const identityContext = identityContextFromIngress(platform, evt, "thread-start");
547
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
548
+ const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, { platform, user, actor: identityContext.actor });
489
549
  for (const h of threadStartedHandlers)
490
- await h({ thread, user: evt.user });
550
+ await h({ thread, user, actor: identityContext.actor });
491
551
  },
492
552
  async onReaction(evt) {
493
553
  // Normalize by source platform, falling back to adapter identity for
@@ -497,14 +557,21 @@ export function createChannel(opts) {
497
557
  ? normalizeEmoji(evt.rawEmoji, sourcePlatform)
498
558
  : undefined;
499
559
  const value = normalized ?? evt.rawEmoji;
500
- const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, { platform: sourcePlatform });
560
+ const identityContext = identityContextFromIngress(sourcePlatform, evt, "reaction");
561
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
562
+ const thread = makeThread(adapter, evt.replyTarget, evt.conversationKey, {
563
+ platform: sourcePlatform,
564
+ user,
565
+ actor: identityContext.actor,
566
+ });
501
567
  // Prefer the adapter's update-capable ref; fall back to the bare id.
502
568
  const messageRef = evt.messageRef ?? { id: evt.messageId };
503
569
  const reactionEvt = {
504
570
  emoji: value,
505
571
  rawEmoji: evt.rawEmoji,
506
572
  added: evt.added,
507
- user: evt.user,
573
+ user,
574
+ actor: identityContext.actor,
508
575
  messageId: evt.messageId,
509
576
  messageRef,
510
577
  threadId: evt.threadId,
@@ -527,7 +594,8 @@ export function createChannel(opts) {
527
594
  emoji: value,
528
595
  rawEmoji: evt.rawEmoji,
529
596
  added: evt.added,
530
- user: evt.user,
597
+ user,
598
+ actor: identityContext.actor,
531
599
  messageId: evt.messageId,
532
600
  thread,
533
601
  messageRef,
@@ -538,15 +606,20 @@ export function createChannel(opts) {
538
606
  const handler = modalSubmitHandlers.get(evt.callbackId);
539
607
  if (!handler)
540
608
  return; // unregistered → closes
609
+ const identityContext = identityContextFromIngress(evt.platform, evt, "modal-submit");
610
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
541
611
  const thread = evt.conversationKey !== undefined && evt.replyTarget !== undefined
542
612
  ? makeThread(adapter, evt.replyTarget, evt.conversationKey, {
543
613
  platform: evt.platform,
614
+ user,
615
+ actor: identityContext.actor,
544
616
  })
545
617
  : undefined;
546
618
  const result = await handler({
547
619
  callbackId: evt.callbackId,
548
620
  values: evt.values,
549
- user: evt.user,
621
+ user,
622
+ actor: identityContext.actor,
550
623
  thread,
551
624
  privateMetadata: evt.privateMetadata,
552
625
  raw: evt.raw,
@@ -557,9 +630,12 @@ export function createChannel(opts) {
557
630
  const handler = modalCloseHandlers.get(evt.callbackId);
558
631
  if (!handler)
559
632
  return;
633
+ const identityContext = identityContextFromIngress(evt.platform, evt, "modal-close");
634
+ const user = await resolveChannelUser(opts.identifyUser, identityContext);
560
635
  await handler({
561
636
  callbackId: evt.callbackId,
562
- user: evt.user,
637
+ user,
638
+ actor: identityContext.actor,
563
639
  privateMetadata: evt.privateMetadata,
564
640
  raw: evt.raw,
565
641
  });
@@ -568,7 +644,6 @@ export function createChannel(opts) {
568
644
  }
569
645
  const channel = {
570
646
  name: opts.name,
571
- ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
572
647
  ...(opts.showToolStatus !== undefined
573
648
  ? { showToolStatus: opts.showToolStatus }
574
649
  : {}),
@@ -590,11 +665,13 @@ export function createChannel(opts) {
590
665
  return transcripts;
591
666
  },
592
667
  ɵruntime: {
668
+ enableIntelligenceMemory() {
669
+ intelligenceMemoryAvailable = true;
670
+ },
593
671
  addAdapter(adapter) {
594
672
  if (started) {
595
673
  throw new Error("channel.ɵruntime.addAdapter must be called before channel.ɵruntime.start()");
596
674
  }
597
- assertExclusive([...adapters, adapter]);
598
675
  adapters.push(adapter);
599
676
  },
600
677
  async start() {
@@ -610,7 +687,6 @@ export function createChannel(opts) {
610
687
  throw new Error(`channel "${opts.name ?? "(unnamed)"}" must register onMention or onMessage before activation`);
611
688
  }
612
689
  started = true;
613
- assertExclusive(adapters);
614
690
  // Resolve persistence now that all adapters (including any attached via
615
691
  // addAdapter) are known, then build the transcript store, action
616
692
  // registry, and register components against it.
@@ -623,7 +699,11 @@ export function createChannel(opts) {
623
699
  });
624
700
  telemetry = tel;
625
701
  const registryInstance = new ActionRegistry({
626
- store: opts.actionStore ?? kvActionStore(backend),
702
+ store: opts.actionStore ??
703
+ kvActionStore(backend, {
704
+ defaultTtlMs: cfg.actionRetentionMs ?? 7 * 24 * 60 * 60 * 1000,
705
+ }),
706
+ retentionMs: cfg.actionRetentionMs ?? 7 * 24 * 60 * 60 * 1000,
627
707
  });
628
708
  registry = registryInstance;
629
709
  for (const c of opts.components ?? []) {
@@ -644,7 +724,6 @@ export function createChannel(opts) {
644
724
  commandsCount: commandHandlers.size,
645
725
  contextCount: context.length,
646
726
  transcripts: !!cfg.transcripts,
647
- identity: !!cfg.identity,
648
727
  });
649
728
  // Isolate per-adapter startup failures: one adapter rejecting (e.g.
650
729
  // Telegram's setMyCommands rejecting a hyphenated command name, a revoked
@@ -669,6 +748,66 @@ export function createChannel(opts) {
669
748
  startedPlatforms.push(platform);
670
749
  }
671
750
  });
751
+ const managedFailureIndex = startResults.findIndex((result, index) => adapters[index]?.__intelligenceChannel === true &&
752
+ result.status === "rejected");
753
+ const stopAdapters = async (indexes, reason) => {
754
+ const rollbackResults = await Promise.allSettled(indexes.map((index) => new Promise((resolve, reject) => {
755
+ let settled = false;
756
+ const timeout = setTimeout(() => {
757
+ if (settled)
758
+ return;
759
+ settled = true;
760
+ reject(new Error(`stop timed out after ${ADAPTER_ROLLBACK_TIMEOUT_MS}ms`));
761
+ }, ADAPTER_ROLLBACK_TIMEOUT_MS);
762
+ void Promise.resolve()
763
+ .then(() => adapters[index].stop())
764
+ .then(() => {
765
+ if (settled)
766
+ return;
767
+ settled = true;
768
+ clearTimeout(timeout);
769
+ resolve();
770
+ }, (error) => {
771
+ if (settled)
772
+ return;
773
+ settled = true;
774
+ clearTimeout(timeout);
775
+ reject(error);
776
+ });
777
+ })));
778
+ rollbackResults.forEach((result, resultIndex) => {
779
+ if (result.status === "rejected") {
780
+ const adapterIndex = indexes[resultIndex];
781
+ console.error(`[channel] adapter "${adapters[adapterIndex].platform}" failed to stop ${reason}:`, result.reason);
782
+ }
783
+ });
784
+ };
785
+ const allAdapterIndexes = adapters.map((_adapter, index) => index);
786
+ const rejectedAdapterIndexes = startResults.flatMap((result, index) => result.status === "rejected" ? [index] : []);
787
+ if (managedFailureIndex >= 0) {
788
+ // Managed activation must never report online merely because a
789
+ // coexisting direct adapter started. Roll back every attempted
790
+ // adapter, including the rejecting managed adapter, because start()
791
+ // may have acquired resources before it failed.
792
+ await stopAdapters(allAdapterIndexes, "after managed activation failed");
793
+ started = false;
794
+ throw new Error(`channel "${opts.name ?? "(unnamed)"}" failed to start its managed Intelligence adapter`);
795
+ }
796
+ // A channel that has adapters but where NONE started is dead — surface an
797
+ // error so the runtime reports status "error", not a false "online". A
798
+ // PARTIAL start (>=1 adapter live) still counts as started. Reset the
799
+ // `started` guard so a caller can retry after fixing the misconfiguration.
800
+ if (adapters.length > 0 && startedPlatforms.length === 0) {
801
+ await stopAdapters(allAdapterIndexes, "after all adapters failed to start");
802
+ started = false;
803
+ throw new Error(`channel "${opts.name ?? "(unnamed)"}" failed to start: all ${failedPlatforms.length} adapter(s) failed to connect (${failedPlatforms.join(", ")}) — see the logged errors above`);
804
+ }
805
+ if (rejectedAdapterIndexes.length > 0) {
806
+ // A rejecting start may still have acquired a listener or provider
807
+ // client. Release only those failed adapters while preserving the
808
+ // healthy adapters that make this a valid degraded start.
809
+ await stopAdapters(rejectedAdapterIndexes, "after its start failed");
810
+ }
672
811
  if (startedPlatforms.length > 0) {
673
812
  tel.capture("oss.channel.started", {
674
813
  platforms: startedPlatforms,
@@ -681,23 +820,18 @@ export function createChannel(opts) {
681
820
  toolsCount: toolMap.size,
682
821
  });
683
822
  }
684
- // A channel that has adapters but where NONE started is dead — surface an
685
- // error so the runtime reports status "error", not a false "online". A
686
- // PARTIAL start (>=1 adapter live) still counts as started. Reset the
687
- // `started` guard so a caller can retry after fixing the misconfiguration.
688
- if (adapters.length > 0 && startedPlatforms.length === 0) {
689
- started = false;
690
- throw new Error(`channel "${opts.name ?? "(unnamed)"}" failed to start: all ${failedPlatforms.length} adapter(s) failed to connect (${failedPlatforms.join(", ")}) — see the logged errors above`);
691
- }
692
823
  // Hand declared commands to adapters that register them up front (e.g.
693
- // Discord); adapters without `registerCommands` are skipped. Per-adapter
694
- // failures are isolated the same way as start().
824
+ // Discord); adapters without `registerCommands` are skipped. Never call
825
+ // into adapters whose start failed and whose resources were just
826
+ // cleaned up. Per-adapter failures are isolated the same way as start().
695
827
  const commandSpecs = [...commandHandlers.values()].map(toCommandSpec);
696
828
  if (commandSpecs.length > 0) {
697
- const registerResults = await Promise.allSettled(adapters.map((a) => a.registerCommands?.(commandSpecs)));
698
- registerResults.forEach((r, i) => {
829
+ const startedAdapterIndexes = startResults.flatMap((result, index) => result.status === "fulfilled" ? [index] : []);
830
+ const registerResults = await Promise.allSettled(startedAdapterIndexes.map((index) => adapters[index].registerCommands?.(commandSpecs)));
831
+ registerResults.forEach((r, resultIndex) => {
699
832
  if (r.status === "rejected") {
700
- console.error(`[channel] adapter "${adapters[i].platform}" failed to register commands:`, r.reason);
833
+ const adapterIndex = startedAdapterIndexes[resultIndex];
834
+ console.error(`[channel] adapter "${adapters[adapterIndex].platform}" failed to register commands:`, r.reason);
701
835
  }
702
836
  });
703
837
  }
@@ -710,7 +844,7 @@ export function createChannel(opts) {
710
844
  started = false;
711
845
  // Isolate per-adapter shutdown failures: one adapter's stop() rejecting
712
846
  // must not prevent the others from being stopped.
713
- const stopResults = await Promise.allSettled(adapters.map((a) => a.stop()));
847
+ const stopResults = await Promise.allSettled(adapters.map((a) => Promise.resolve().then(() => a.stop())));
714
848
  stopResults.forEach((r, i) => {
715
849
  if (r.status === "rejected") {
716
850
  console.error(`[channel] adapter "${adapters[i].platform}" failed to stop:`, r.reason);
@@ -728,6 +862,9 @@ export function createChannel(opts) {
728
862
  onMessage(h) {
729
863
  messageHandlers.push(h);
730
864
  },
865
+ onWelcome(h) {
866
+ welcomeHandlers.push(h);
867
+ },
731
868
  onThreadStarted(h) {
732
869
  threadStartedHandlers.push(h);
733
870
  },