twilio-agent-connect 2.1.0 → 2.2.0

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.
package/dist/index.d.ts CHANGED
@@ -104,8 +104,9 @@ declare const EnvironmentVariables: {
104
104
  * Memory retrieval mode for channels.
105
105
  *
106
106
  * - "always": Fetch memory with the message as query on every inbound message.
107
- * - "once": Fetch memory once at conversation start with an empty query and
108
- * cache it. The cache is invalidated when the conversation becomes INACTIVE.
107
+ * - "once": Fetch memory once at conversation start with no query (and no
108
+ * conversation id, skipping query expansion) and cache it. The cache is
109
+ * invalidated when the conversation becomes INACTIVE.
109
110
  * - "never": Never automatically fetch memory (default).
110
111
  */
111
112
  declare const MemoryModeSchema: z.ZodEnum<{
@@ -1483,8 +1484,8 @@ interface InitiateConversationResult {
1483
1484
  /**
1484
1485
  * Result of initiating an outbound voice conversation.
1485
1486
  * Note: conversationId is not included because the conversation is created by
1486
- * Conversation Orchestrator during passive hydration — the SDK discovers it
1487
- * lazily on the first prompt via callSid lookup.
1487
+ * Conversation Orchestrator during passive hydration — the SDK discovers it by
1488
+ * callSid, in the background from WebSocket setup.
1488
1489
  */
1489
1490
  interface InitiateVoiceConversationResult {
1490
1491
  callSid: string;
@@ -2481,7 +2482,7 @@ declare class TACConfig {
2481
2482
  * Optional environment variables:
2482
2483
  * - TWILIO_WHATSAPP_NUMBER: WhatsApp number for WhatsApp channel (e.g., 'whatsapp:+1234567890')
2483
2484
  * - TWILIO_CONVERSATION_CONFIGURATION_ID: Conversation Orchestrator configuration ID (enables orchestrated mode)
2484
- * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; domain only, without protocol/port/path, e.g., 'abc123.ngrok.app')
2485
+ * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; a port and/or base path are allowed, e.g., 'abc123.ngrok.app', 'example.ngrok.app:8080', or 'example.com/server1')
2485
2486
  * - TWILIO_VOICE_WEBSOCKET_PATH: Path for the voice WebSocket (default: /ws)
2486
2487
  * - TWILIO_VOICE_ACTION_PATH: Path for the ConversationRelay action callback (default: /conversation-relay-callback)
2487
2488
  * - TWILIO_VOICE_CALL_EVENT_PATH: Base path for the call-event callbacks — status, async AMD, recording (default: /twilio/call-events)
@@ -2846,7 +2847,7 @@ interface BaseChannelOptions {
2846
2847
  *
2847
2848
  * - "never": Memory is not automatically retrieved. Use the memory TAC tool or manually call `tac.retrieveMemory()` in callbacks for conditional retrieval.
2848
2849
  * - "always": Memory is automatically retrieved (using the message as query) for every inbound message and available in `onMessageReady` callback.
2849
- * - "once": Memory is retrieved once at conversation start with an empty query and cached on the session. Subsequent messages reuse the cache until the conversation becomes INACTIVE.
2850
+ * - "once": Memory is retrieved once at conversation start with no query (and no conversation id) and cached on the session. Subsequent messages reuse the cache until the conversation becomes INACTIVE.
2850
2851
  */
2851
2852
  memoryMode?: MemoryMode;
2852
2853
  /**
@@ -2984,9 +2985,9 @@ declare abstract class BaseChannel {
2984
2985
  *
2985
2986
  * Modes:
2986
2987
  * - "always": Fetch with the provided query on every message.
2987
- * - "once": Fetch once with an empty query and cache the result on the
2988
- * session. Subsequent calls reuse the cache until it is invalidated on the
2989
- * INACTIVE transition.
2988
+ * - "once": Fetch once with neither a query nor a conversation id (skipping
2989
+ * query expansion) and cache the result on the session. Subsequent calls
2990
+ * reuse the cache until it is invalidated on the INACTIVE transition.
2990
2991
  * - "never": Skip retrieval.
2991
2992
  *
2992
2993
  * Memory retrieval failures are logged and swallowed so message processing
@@ -3137,6 +3138,10 @@ declare class TAC {
3137
3138
  *
3138
3139
  * @param session - Conversation session context
3139
3140
  * @param query - Optional semantic search query
3141
+ * @param conversationId - Passed through to `/Recall` as-is. Sending one
3142
+ * without a `query` makes Memory infer one from that conversation's history
3143
+ * — an expensive server-side step — so leave it unset when there is no
3144
+ * per-turn topic (e.g. `"once"` mode's cache-priming fetch).
3140
3145
  * @returns Promise containing TACMemoryResponse wrapper providing unified access to memory data.
3141
3146
  *
3142
3147
  * Attempts to retrieve from Memory API first:
@@ -3147,7 +3152,7 @@ declare class TAC {
3147
3152
  * - observations and summaries are empty arrays
3148
3153
  * - communications have basic fields only (no author name/type)
3149
3154
  */
3150
- retrieveMemory(session: ConversationSession, query?: string): Promise<TACMemoryResponse>;
3155
+ retrieveMemory(session: ConversationSession, query?: string, conversationId?: string): Promise<TACMemoryResponse>;
3151
3156
  /**
3152
3157
  * Fetch profile information with traits
3153
3158
  *
@@ -3549,6 +3554,10 @@ interface VoiceChannelEvents extends BaseChannelEvents {
3549
3554
  utteranceUntilInterrupt: string | undefined;
3550
3555
  durationUntilInterruptMs: number | undefined;
3551
3556
  }) => void;
3557
+ /**
3558
+ * Fired once the session and WebSocket registration exist — in orchestrated
3559
+ * mode possibly before the first prompt, since the lookup starts at setup.
3560
+ */
3552
3561
  onWebSocketConnected?: (data: {
3553
3562
  conversationId: ConversationId;
3554
3563
  }) => void;
@@ -3706,6 +3715,13 @@ declare class VoiceChannel extends BaseChannel {
3706
3715
  * Get active WebSocket connection for a conversation
3707
3716
  */
3708
3717
  getWebsocket(conversationId: ConversationId): WebSocket | null;
3718
+ /**
3719
+ * Poll Conversation Orchestrator for the conversation ConversationRelay
3720
+ * created for `callSid`, then register the local session and WebSocket.
3721
+ * Runs in the background from `setup`, so those can exist before the
3722
+ * caller speaks.
3723
+ */
3724
+ private initializeOrchestratedConversation;
3709
3725
  /**
3710
3726
  * Handle WebSocket connection from ConversationRelay
3711
3727
  */
@@ -3860,8 +3876,8 @@ declare class VoiceChannel extends BaseChannel {
3860
3876
  *
3861
3877
  * Places an outbound call with inline TwiML that connects to ConversationRelay.
3862
3878
  * The conversationConfiguration attribute tells CO to create and manage the
3863
- * conversation during passive hydration. The session is initialized lazily
3864
- * on the first prompt when the conversation is discovered by callSid.
3879
+ * conversation during passive hydration. The session is initialized when the
3880
+ * background callSid lookup started at WebSocket setup finds it.
3865
3881
  *
3866
3882
  * TwiML fields are merged per-field, highest precedence first:
3867
3883
  * 1. `options.twimlOptions` — per-call overrides
@@ -3959,9 +3975,8 @@ declare class VoiceChannel extends BaseChannel {
3959
3975
  /**
3960
3976
  * Hang up a call and clean up its ConversationRelay session.
3961
3977
  *
3962
- * Works on `callSid` alone, in any mode and before a session exists, so it's
3963
- * safe from a call-event handler that fires before the first prompt. Session
3964
- * cleanup no-ops if no tracked session matches.
3978
+ * Works on `callSid` alone, whether or not a session exists yet. No-ops the
3979
+ * session cleanup if none is tracked.
3965
3980
  *
3966
3981
  * Does not throw — hanging up an already-ended call is routine (the callee
3967
3982
  * hangs up while AMD is still resolving), and handlers shouldn't have to
@@ -3981,11 +3996,10 @@ declare class VoiceChannel extends BaseChannel {
3981
3996
  * keyed by conversation id: the Orchestrator conversation id in orchestrator
3982
3997
  * mode, the CallSid only in ConversationRelay-only mode.
3983
3998
  *
3984
- * Sessions are created on the caller's first prompt, not at WebSocket setup,
3985
- * so this returns `undefined` for a call that connected but hasn't been spoken
3986
- * into. That includes `onAmd` under `machineDetection: 'Enable'`, which fires
3987
- * before the first prompt by design — hang up with {@link endCall}, which
3988
- * needs no session.
3999
+ * Relay-only mode creates the session on the first prompt; orchestrated
4000
+ * mode creates it when the lookup started at setup finishes, so it may
4001
+ * exist before the caller speaks — including before `onAmd` fires. Treat it
4002
+ * as racy and hang up with {@link endCall}, which needs no session.
3989
4003
  *
3990
4004
  * At the other end, orchestrator mode keeps the session until Conversation
3991
4005
  * Orchestrator's CLOSED webhook, so it outlives the call and `onCallStatus` /
@@ -4004,8 +4018,8 @@ declare class VoiceChannel extends BaseChannel {
4004
4018
  *
4005
4019
  * @param callSid - Twilio Call SID, e.g. from
4006
4020
  * `InitiateVoiceConversationResult.callSid` or a call event.
4007
- * @returns The session, or `undefined` — no first prompt yet, the call ended,
4008
- * or it landed on another instance (see the horizontal-scaling note in
4021
+ * @returns The session, or `undefined` — not created yet, the call ended, or
4022
+ * it landed on another instance (see the horizontal-scaling note in
4009
4023
  * CLAUDE.md).
4010
4024
  */
4011
4025
  getConversationSessionByCallSid(callSid: string): ConversationSession | undefined;
package/dist/index.js CHANGED
@@ -17,8 +17,10 @@ var TwilioMemoryConfigSchema = z.object({
17
17
  traitGroups: z.array(z.string()).optional(),
18
18
  observationsLimit: z.number().int().min(0).max(100).default(20),
19
19
  summariesLimit: z.number().int().min(0).max(100).default(5),
20
- // API default is 0 (no communications fetched). SDK defaults to 10 for a useful out-of-box experience.
21
- communicationsLimit: z.number().int().min(0).max(100).default(10),
20
+ // 0 matches the Memory API and Python SDK. Above 0, Memory requires a
21
+ // conversationId on every /Recall, forcing the query expansion that
22
+ // `memoryMode: "once"` exists to avoid.
23
+ communicationsLimit: z.number().int().min(0).max(100).default(0),
22
24
  relevanceThreshold: z.number().min(0).max(1).default(0),
23
25
  /**
24
26
  * Trait group name that holds the phone identifier on newly created profiles.
@@ -49,34 +51,28 @@ var TACConfigSchema = z.object({
49
51
  memoryConfig: TwilioMemoryConfigSchema.prefault({}),
50
52
  conversationConfigurationId: z.string().regex(/^conv_configuration_[0-9a-z]{26}$/, "Invalid Conversation Configuration ID format").optional(),
51
53
  /**
52
- * Public domain where voice routes are reachable (e.g. "abc123.ngrok.app").
53
- * Used by VoiceChannel to construct the public WebSocket URL and
54
- * ConversationRelay action URL. Required when using the Voice channel.
55
- *
56
- * Schemes (https://, wss://), surrounding whitespace, and trailing slashes
57
- * are stripped automatically before validation a naive copy-paste from a
58
- * browser address bar like "https://example.ngrok.app/" is normalized to
59
- * "example.ngrok.app" rather than rejected.
60
- */
61
- voicePublicDomain: z.preprocess(
62
- (v) => {
63
- if (typeof v !== "string") return v;
64
- let s = v.trim();
65
- if (s.length === 0) return void 0;
66
- for (const scheme of ["https://", "http://", "wss://", "ws://"]) {
67
- if (s.toLowerCase().startsWith(scheme)) {
68
- s = s.slice(scheme.length);
69
- break;
70
- }
54
+ * Public domain where voice routes are reachable, optionally including a port
55
+ * and/or base path (e.g. "example.ngrok.app", "example.ngrok.app:8080",
56
+ * or "example.com/server1"). Used by VoiceChannel to construct the public
57
+ * WebSocket URL and ConversationRelay action URL. Required when using the Voice channel.
58
+ *
59
+ * Whitespace, schemes (https://, wss://), and trailing slashes are stripped
60
+ * automatically; anything else is passed through as given. Mirrors the Python
61
+ * SDK's `_normalize_voice_public_domain` keep the two in step.
62
+ */
63
+ voicePublicDomain: z.preprocess((v) => {
64
+ if (typeof v !== "string") return v;
65
+ let s = v.trim();
66
+ if (s.length === 0) return void 0;
67
+ for (const scheme of ["https://", "http://", "wss://", "ws://"]) {
68
+ if (s.toLowerCase().startsWith(scheme)) {
69
+ s = s.slice(scheme.length);
70
+ break;
71
71
  }
72
- s = s.replace(/\/+$/, "");
73
- return s.length === 0 ? void 0 : s;
74
- },
75
- z.string().max(253, "Hostname too long (max 253 characters)").regex(
76
- /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
77
- 'Invalid hostname format. Must be a hostname without protocol, port, or path (e.g., "abc123.ngrok.app", "localhost", or "192.168.1.100")'
78
- ).optional()
79
- ).optional(),
72
+ }
73
+ s = s.replace(/\/+$/, "");
74
+ return s.length === 0 ? void 0 : s;
75
+ }, z.string().optional()).optional(),
80
76
  /**
81
77
  * Path the voice WebSocket is served at. Combined with voicePublicDomain to
82
78
  * build the public WebSocket URL the voice channel hands to Twilio in TwiML;
@@ -1429,7 +1425,7 @@ var TACConfig = class _TACConfig {
1429
1425
  * Optional environment variables:
1430
1426
  * - TWILIO_WHATSAPP_NUMBER: WhatsApp number for WhatsApp channel (e.g., 'whatsapp:+1234567890')
1431
1427
  * - TWILIO_CONVERSATION_CONFIGURATION_ID: Conversation Orchestrator configuration ID (enables orchestrated mode)
1432
- * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; domain only, without protocol/port/path, e.g., 'abc123.ngrok.app')
1428
+ * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; a port and/or base path are allowed, e.g., 'abc123.ngrok.app', 'example.ngrok.app:8080', or 'example.com/server1')
1433
1429
  * - TWILIO_VOICE_WEBSOCKET_PATH: Path for the voice WebSocket (default: /ws)
1434
1430
  * - TWILIO_VOICE_ACTION_PATH: Path for the ConversationRelay action callback (default: /conversation-relay-callback)
1435
1431
  * - TWILIO_VOICE_CALL_EVENT_PATH: Base path for the call-event callbacks — status, async AMD, recording (default: /twilio/call-events)
@@ -1685,7 +1681,7 @@ function createLogger(options) {
1685
1681
 
1686
1682
  // package.json
1687
1683
  var package_default = {
1688
- version: "2.1.0"};
1684
+ version: "2.2.0"};
1689
1685
  function buildUserAgent() {
1690
1686
  return `twilio-agent-connect-typescript/${package_default.version}`;
1691
1687
  }
@@ -1854,7 +1850,9 @@ var MemoryClient = class _MemoryClient extends BaseClient {
1854
1850
  profile_id: profileId,
1855
1851
  observation_count: observations.length,
1856
1852
  summary_count: summaries.length,
1857
- communication_count: communications.length
1853
+ communication_count: communications.length,
1854
+ // Server-side query time; dominates /Recall when expansion runs.
1855
+ query_time_ms: response.meta?.queryTime
1858
1856
  },
1859
1857
  "Memory retrieval succeeded"
1860
1858
  );
@@ -3045,6 +3043,10 @@ var TAC = class _TAC {
3045
3043
  *
3046
3044
  * @param session - Conversation session context
3047
3045
  * @param query - Optional semantic search query
3046
+ * @param conversationId - Passed through to `/Recall` as-is. Sending one
3047
+ * without a `query` makes Memory infer one from that conversation's history
3048
+ * — an expensive server-side step — so leave it unset when there is no
3049
+ * per-turn topic (e.g. `"once"` mode's cache-priming fetch).
3048
3050
  * @returns Promise containing TACMemoryResponse wrapper providing unified access to memory data.
3049
3051
  *
3050
3052
  * Attempts to retrieve from Memory API first:
@@ -3055,7 +3057,7 @@ var TAC = class _TAC {
3055
3057
  * - observations and summaries are empty arrays
3056
3058
  * - communications have basic fields only (no author name/type)
3057
3059
  */
3058
- async retrieveMemory(session, query) {
3060
+ async retrieveMemory(session, query, conversationId) {
3059
3061
  if (!this.isOrchestratorEnabled()) {
3060
3062
  return new TACMemoryResponse([]);
3061
3063
  }
@@ -3102,7 +3104,7 @@ var TAC = class _TAC {
3102
3104
  throw new Error("Memory client is not available");
3103
3105
  }
3104
3106
  const memoryResponse = await this.memoryClient.retrieveMemories(session.profileId, {
3105
- conversationId: session.conversationId,
3107
+ conversationId,
3106
3108
  query,
3107
3109
  observationsLimit: this.config.memoryConfig.observationsLimit,
3108
3110
  summariesLimit: this.config.memoryConfig.summariesLimit,
@@ -3448,9 +3450,9 @@ var BaseChannel = class {
3448
3450
  *
3449
3451
  * Modes:
3450
3452
  * - "always": Fetch with the provided query on every message.
3451
- * - "once": Fetch once with an empty query and cache the result on the
3452
- * session. Subsequent calls reuse the cache until it is invalidated on the
3453
- * INACTIVE transition.
3453
+ * - "once": Fetch once with neither a query nor a conversation id (skipping
3454
+ * query expansion) and cache the result on the session. Subsequent calls
3455
+ * reuse the cache until it is invalidated on the INACTIVE transition.
3454
3456
  * - "never": Skip retrieval.
3455
3457
  *
3456
3458
  * Memory retrieval failures are logged and swallowed so message processing
@@ -3465,9 +3467,11 @@ var BaseChannel = class {
3465
3467
  return session.cachedMemory;
3466
3468
  }
3467
3469
  try {
3470
+ const isAlways = this.memoryMode === "always";
3468
3471
  const memory = await this.tac.retrieveMemory(
3469
3472
  session,
3470
- this.memoryMode === "always" ? query : void 0
3473
+ isAlways ? query : void 0,
3474
+ isAlways ? session.conversationId : void 0
3471
3475
  );
3472
3476
  if (this.memoryMode === "once") {
3473
3477
  session.cachedMemory = memory;
@@ -4673,6 +4677,9 @@ function studioVoiceHandoffUrl(accountSid, flowSid) {
4673
4677
 
4674
4678
  // packages/core/src/channels/voice.ts
4675
4679
  var DEFAULT_WELCOME_GREETING = "Hello! How can I assist you today?";
4680
+ var POLL_ATTEMPTS = 10;
4681
+ var POLL_BASE_DELAY_MS = 250;
4682
+ var POLL_MAX_DELAY_MS = 1500;
4676
4683
  function stringifyParameterValue(value) {
4677
4684
  if (typeof value === "object") {
4678
4685
  return JSON.stringify(value);
@@ -4927,6 +4934,57 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4927
4934
  getWebsocket(conversationId) {
4928
4935
  return this.webSocketConnections.get(conversationId) || null;
4929
4936
  }
4937
+ /**
4938
+ * Poll Conversation Orchestrator for the conversation ConversationRelay
4939
+ * created for `callSid`, then register the local session and WebSocket.
4940
+ * Runs in the background from `setup`, so those can exist before the
4941
+ * caller speaks.
4942
+ */
4943
+ async initializeOrchestratedConversation(callSid, fromNumber, ws) {
4944
+ if (!this.conversationClient) {
4945
+ throw new Error("Conversation client is required in orchestrated mode");
4946
+ }
4947
+ let conversations = [];
4948
+ for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
4949
+ conversations = await this.conversationClient.listConversations({
4950
+ channelId: callSid,
4951
+ status: ["ACTIVE"]
4952
+ });
4953
+ if (conversations.length === 1) break;
4954
+ if (attempt < POLL_ATTEMPTS - 1) {
4955
+ this.logger.debug(
4956
+ { call_sid: callSid, attempt: attempt + 1, found: conversations.length },
4957
+ "Conversation not ready yet, polling again"
4958
+ );
4959
+ const delayMs = Math.min(POLL_BASE_DELAY_MS * 2 ** attempt, POLL_MAX_DELAY_MS);
4960
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
4961
+ }
4962
+ }
4963
+ if (conversations.length !== 1) {
4964
+ throw new Error(
4965
+ `Expected exactly 1 conversation for callSid ${callSid}, but found ${conversations.length} after ${POLL_ATTEMPTS} attempts`
4966
+ );
4967
+ }
4968
+ const conversation = conversations[0];
4969
+ const conversationId = conversation.id;
4970
+ const participants = await this.conversationClient.listParticipants(conversationId);
4971
+ const customerParticipant = participants.find((p) => p.type === "CUSTOMER");
4972
+ const customerAddress = customerParticipant?.addresses?.find((a) => a.channel === "VOICE")?.address ?? fromNumber ?? void 0;
4973
+ const profileId = customerParticipant?.profileId ? customerParticipant.profileId : void 0;
4974
+ this.webSocketConnections.set(conversationId, ws);
4975
+ this.callSidToConversationId.set(callSid, conversationId);
4976
+ const session = this.startConversation(conversationId, profileId);
4977
+ session.callSid = callSid;
4978
+ if (customerAddress) {
4979
+ session.authorInfo = {
4980
+ address: customerAddress
4981
+ };
4982
+ }
4983
+ if (this.voiceCallbacks.onWebSocketConnected) {
4984
+ this.voiceCallbacks.onWebSocketConnected({ conversationId });
4985
+ }
4986
+ return conversationId;
4987
+ }
4930
4988
  /**
4931
4989
  * Handle WebSocket connection from ConversationRelay
4932
4990
  */
@@ -4935,6 +4993,7 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4935
4993
  let callSid = null;
4936
4994
  let fromNumber = null;
4937
4995
  let initializationFailed = false;
4996
+ let initPromise = null;
4938
4997
  ws.on("message", (data) => {
4939
4998
  (async () => {
4940
4999
  try {
@@ -4957,6 +5016,14 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4957
5016
  case "setup":
4958
5017
  callSid = message.callSid;
4959
5018
  fromNumber = message.from;
5019
+ if (this.tac.isOrchestratorEnabled()) {
5020
+ this.logger.debug(
5021
+ { call_sid: callSid },
5022
+ "Starting background conversation initialization"
5023
+ );
5024
+ initPromise = this.initializeOrchestratedConversation(callSid, fromNumber, ws);
5025
+ void initPromise.catch(() => void 0);
5026
+ }
4960
5027
  if (this.voiceCallbacks.onSetup) {
4961
5028
  this.voiceCallbacks.onSetup({
4962
5029
  callSid,
@@ -4990,50 +5057,21 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4990
5057
  if (fromNumber) {
4991
5058
  session.authorInfo = { address: fromNumber };
4992
5059
  }
4993
- } else {
4994
- if (!this.conversationClient) {
4995
- throw new Error("Conversation client is required in orchestrated mode");
4996
- }
4997
- const POLL_ATTEMPTS = 5;
4998
- const POLL_DELAY_MS = 500;
4999
- let conversations = [];
5000
- for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
5001
- conversations = await this.conversationClient.listConversations({
5002
- channelId: callSid
5003
- });
5004
- if (conversations.length === 1) break;
5005
- if (attempt < POLL_ATTEMPTS - 1) {
5006
- this.logger.debug(
5007
- { call_sid: callSid, attempt: attempt + 1, found: conversations.length },
5008
- "Conversation not ready yet, polling again"
5009
- );
5010
- await new Promise((resolve) => setTimeout(resolve, POLL_DELAY_MS));
5011
- }
5060
+ if (this.voiceCallbacks.onWebSocketConnected) {
5061
+ this.voiceCallbacks.onWebSocketConnected({ conversationId });
5012
5062
  }
5013
- if (conversations.length !== 1) {
5014
- throw new Error(
5015
- `Expected exactly 1 conversation for callSid ${callSid}, but found ${conversations.length} after ${POLL_ATTEMPTS} attempts`
5016
- );
5017
- }
5018
- const conversation = conversations[0];
5019
- conversationId = conversation.id;
5020
- const participants = await this.conversationClient.listParticipants(conversationId);
5021
- const customerParticipant = participants.find((p) => p.type === "CUSTOMER");
5022
- const customerAddress = customerParticipant?.addresses?.find((a) => a.channel === "VOICE")?.address ?? fromNumber ?? void 0;
5023
- const profileId = customerParticipant?.profileId ? customerParticipant.profileId : void 0;
5024
- this.webSocketConnections.set(conversationId, ws);
5025
- this.callSidToConversationId.set(callSid, conversationId);
5026
- const session = this.startConversation(conversationId, profileId);
5027
- session.callSid = callSid;
5028
- if (customerAddress) {
5029
- session.authorInfo = {
5030
- address: customerAddress
5031
- };
5063
+ } else {
5064
+ initPromise ??= this.initializeOrchestratedConversation(
5065
+ callSid,
5066
+ fromNumber,
5067
+ ws
5068
+ );
5069
+ try {
5070
+ conversationId = await initPromise;
5071
+ } finally {
5072
+ initPromise = null;
5032
5073
  }
5033
5074
  }
5034
- if (this.voiceCallbacks.onWebSocketConnected) {
5035
- this.voiceCallbacks.onWebSocketConnected({ conversationId });
5036
- }
5037
5075
  initializationFailed = false;
5038
5076
  this.initializationRetries.delete(callSid);
5039
5077
  this.logger.info(
@@ -5090,6 +5128,19 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5090
5128
  });
5091
5129
  });
5092
5130
  ws.on("close", () => {
5131
+ const pendingInit = initPromise;
5132
+ initPromise = null;
5133
+ if (pendingInit && !conversationId) {
5134
+ void pendingInit.then(async (adoptedId) => {
5135
+ await this.handleWebSocketDisconnect(adoptedId);
5136
+ if (callSid) this.callSidToConversationId.delete(callSid);
5137
+ }).catch((err) => {
5138
+ this.logger.error(
5139
+ { err, call_sid: callSid },
5140
+ "Background conversation initialization failed after the call ended"
5141
+ );
5142
+ });
5143
+ }
5093
5144
  if (conversationId) {
5094
5145
  void this.handleWebSocketDisconnect(conversationId).catch((err) => {
5095
5146
  this.logger.error(
@@ -5476,8 +5527,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5476
5527
  *
5477
5528
  * Places an outbound call with inline TwiML that connects to ConversationRelay.
5478
5529
  * The conversationConfiguration attribute tells CO to create and manage the
5479
- * conversation during passive hydration. The session is initialized lazily
5480
- * on the first prompt when the conversation is discovered by callSid.
5530
+ * conversation during passive hydration. The session is initialized when the
5531
+ * background callSid lookup started at WebSocket setup finds it.
5481
5532
  *
5482
5533
  * TwiML fields are merged per-field, highest precedence first:
5483
5534
  * 1. `options.twimlOptions` — per-call overrides
@@ -5668,9 +5719,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5668
5719
  /**
5669
5720
  * Hang up a call and clean up its ConversationRelay session.
5670
5721
  *
5671
- * Works on `callSid` alone, in any mode and before a session exists, so it's
5672
- * safe from a call-event handler that fires before the first prompt. Session
5673
- * cleanup no-ops if no tracked session matches.
5722
+ * Works on `callSid` alone, whether or not a session exists yet. No-ops the
5723
+ * session cleanup if none is tracked.
5674
5724
  *
5675
5725
  * Does not throw — hanging up an already-ended call is routine (the callee
5676
5726
  * hangs up while AMD is still resolving), and handlers shouldn't have to
@@ -5704,11 +5754,10 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5704
5754
  * keyed by conversation id: the Orchestrator conversation id in orchestrator
5705
5755
  * mode, the CallSid only in ConversationRelay-only mode.
5706
5756
  *
5707
- * Sessions are created on the caller's first prompt, not at WebSocket setup,
5708
- * so this returns `undefined` for a call that connected but hasn't been spoken
5709
- * into. That includes `onAmd` under `machineDetection: 'Enable'`, which fires
5710
- * before the first prompt by design — hang up with {@link endCall}, which
5711
- * needs no session.
5757
+ * Relay-only mode creates the session on the first prompt; orchestrated
5758
+ * mode creates it when the lookup started at setup finishes, so it may
5759
+ * exist before the caller speaks — including before `onAmd` fires. Treat it
5760
+ * as racy and hang up with {@link endCall}, which needs no session.
5712
5761
  *
5713
5762
  * At the other end, orchestrator mode keeps the session until Conversation
5714
5763
  * Orchestrator's CLOSED webhook, so it outlives the call and `onCallStatus` /
@@ -5727,8 +5776,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5727
5776
  *
5728
5777
  * @param callSid - Twilio Call SID, e.g. from
5729
5778
  * `InitiateVoiceConversationResult.callSid` or a call event.
5730
- * @returns The session, or `undefined` — no first prompt yet, the call ended,
5731
- * or it landed on another instance (see the horizontal-scaling note in
5779
+ * @returns The session, or `undefined` — not created yet, the call ended, or
5780
+ * it landed on another instance (see the horizontal-scaling note in
5732
5781
  * CLAUDE.md).
5733
5782
  */
5734
5783
  getConversationSessionByCallSid(callSid) {