@convai/web-sdk 1.8.0-beta.5 → 1.8.0-beta.7

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 (110) hide show
  1. package/README.md +333 -13
  2. package/dist/core/CharacterRoster.d.ts +84 -0
  3. package/dist/core/CharacterRoster.d.ts.map +1 -0
  4. package/dist/core/CharacterRoster.js +348 -0
  5. package/dist/core/CharacterRoster.js.map +1 -0
  6. package/dist/core/ConvaiClient.d.ts +152 -2
  7. package/dist/core/ConvaiClient.d.ts.map +1 -1
  8. package/dist/core/ConvaiClient.js +1036 -49
  9. package/dist/core/ConvaiClient.js.map +1 -1
  10. package/dist/core/ConvaiRoomError.d.ts +48 -0
  11. package/dist/core/ConvaiRoomError.d.ts.map +1 -0
  12. package/dist/core/ConvaiRoomError.js +111 -0
  13. package/dist/core/ConvaiRoomError.js.map +1 -0
  14. package/dist/core/MessageHandler.d.ts +25 -0
  15. package/dist/core/MessageHandler.d.ts.map +1 -1
  16. package/dist/core/MessageHandler.js +431 -30
  17. package/dist/core/MessageHandler.js.map +1 -1
  18. package/dist/core/SSESession.d.ts +15 -0
  19. package/dist/core/SSESession.d.ts.map +1 -1
  20. package/dist/core/SSESession.js +92 -3
  21. package/dist/core/SSESession.js.map +1 -1
  22. package/dist/core/connectRequest.d.ts +38 -2
  23. package/dist/core/connectRequest.d.ts.map +1 -1
  24. package/dist/core/connectRequest.js +57 -0
  25. package/dist/core/connectRequest.js.map +1 -1
  26. package/dist/core/index.d.ts +6 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +5 -1
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/logicalTurn.d.ts +4 -1
  31. package/dist/core/logicalTurn.d.ts.map +1 -1
  32. package/dist/core/logicalTurn.js +35 -4
  33. package/dist/core/logicalTurn.js.map +1 -1
  34. package/dist/core/modelOutput.d.ts +40 -0
  35. package/dist/core/modelOutput.d.ts.map +1 -0
  36. package/dist/core/modelOutput.js +187 -0
  37. package/dist/core/modelOutput.js.map +1 -0
  38. package/dist/core/publishedChat.d.ts +9 -0
  39. package/dist/core/publishedChat.d.ts.map +1 -0
  40. package/dist/core/publishedChat.js +13 -0
  41. package/dist/core/publishedChat.js.map +1 -0
  42. package/dist/core/rosterRequest.d.ts +57 -0
  43. package/dist/core/rosterRequest.d.ts.map +1 -0
  44. package/dist/core/rosterRequest.js +210 -0
  45. package/dist/core/rosterRequest.js.map +1 -0
  46. package/dist/core/types.d.ts +667 -10
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/types.js.map +1 -1
  49. package/dist/embed/ConvaiChatElement.d.ts +27 -0
  50. package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
  51. package/dist/embed/ConvaiChatElement.js +300 -0
  52. package/dist/embed/ConvaiChatElement.js.map +1 -0
  53. package/dist/embed/browser.d.ts +3 -0
  54. package/dist/embed/browser.d.ts.map +1 -0
  55. package/dist/embed/browser.js +5 -0
  56. package/dist/embed/browser.js.map +1 -0
  57. package/dist/embed/chat-embed-v1.js +100 -0
  58. package/dist/embed/chat-embed-v1.js.map +7 -0
  59. package/dist/embed/index.d.ts +9 -0
  60. package/dist/embed/index.d.ts.map +1 -0
  61. package/dist/embed/index.js +9 -0
  62. package/dist/embed/index.js.map +1 -0
  63. package/dist/embed/publicationApi.d.ts +23 -0
  64. package/dist/embed/publicationApi.d.ts.map +1 -0
  65. package/dist/embed/publicationApi.js +141 -0
  66. package/dist/embed/publicationApi.js.map +1 -0
  67. package/dist/react/components/ConvaiWidget.d.ts +11 -1
  68. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  69. package/dist/react/components/ConvaiWidget.js +67 -11
  70. package/dist/react/components/ConvaiWidget.js.map +1 -1
  71. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  72. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
  73. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  74. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
  75. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
  76. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
  77. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
  78. package/dist/react/hooks/useConvaiClient.d.ts +2 -0
  79. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  80. package/dist/react/hooks/useConvaiClient.js +16 -0
  81. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  82. package/dist/react/index.d.ts +1 -1
  83. package/dist/react/index.d.ts.map +1 -1
  84. package/dist/react/index.js +1 -1
  85. package/dist/react/index.js.map +1 -1
  86. package/dist/utils/inlineMarkdown.d.ts +38 -0
  87. package/dist/utils/inlineMarkdown.d.ts.map +1 -0
  88. package/dist/utils/inlineMarkdown.js +106 -0
  89. package/dist/utils/inlineMarkdown.js.map +1 -0
  90. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  91. package/dist/vanilla/AudioRenderer.js +6 -0
  92. package/dist/vanilla/AudioRenderer.js.map +1 -1
  93. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  94. package/dist/vanilla/ConvaiWidget.js +224 -89
  95. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  96. package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
  97. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
  98. package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
  99. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
  100. package/dist/vanilla/index.d.ts +2 -1
  101. package/dist/vanilla/index.d.ts.map +1 -1
  102. package/dist/vanilla/index.js +2 -1
  103. package/dist/vanilla/index.js.map +1 -1
  104. package/dist/vanilla/types.d.ts +8 -1
  105. package/dist/vanilla/types.d.ts.map +1 -1
  106. package/dist/vanilla/types.js.map +1 -1
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/version.js.map +1 -1
  110. package/package.json +19 -6
@@ -5,16 +5,20 @@ import { AudioManager } from "./AudioManager.js";
5
5
  import { VideoManager } from "./VideoManager.js";
6
6
  import { ScreenShareManager } from "./ScreenShareManager.js";
7
7
  import { MessageHandler } from "./MessageHandler.js";
8
+ import { CharacterRoster } from "./CharacterRoster.js";
9
+ import { buildJoinRequestBody, buildRosterConnectFields, isRosterConfig, validateJoinOptions, validateProtocolTopology, validateRosterConfig, } from "./rosterRequest.js";
10
+ import { ConvaiRoomError, roomErrorFromResponse } from "./ConvaiRoomError.js";
8
11
  import { SDK_VERSION } from "../version.js";
9
12
  import { EventEmitter } from "./EventEmitter.js";
10
13
  import { MemoryManager } from "./MemoryManager.js";
11
14
  import { CharacterVersionManager } from "./CharacterVersionManager.js";
12
15
  import { resolveCharacterReference, } from "./characterReference.js";
13
16
  import { ConnectionStateHandler } from "./ConnectionStateHandler.js";
14
- import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, } from "./connectRequest.js";
17
+ import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, serializeProtocolCapabilities, validateActionProtocolSelection, validateModelOutputVersionSelection, } from "./connectRequest.js";
15
18
  import { shouldPreemptForContextUpdateResponse, shouldPreemptForExplicitRespondMode, } from "./contextUpdateRequest.js";
16
19
  import { resolveVisionInputConfig, serializeVisionInputConfig, } from "./visionRequest.js";
17
20
  import { SSESession } from "./SSESession.js";
21
+ import { isJsonValue } from "./modelOutput.js";
18
22
  import { isValidLogicalTurnId } from "./logicalTurn.js";
19
23
  // Module-level registry — populated by importing @convai/web-sdk/vanilla/websocket
20
24
  let _wsFactory = null;
@@ -24,6 +28,33 @@ function normalizeStateOfMind(value) {
24
28
  const normalized = value.trim().toLowerCase();
25
29
  return normalized === "neutral" || normalized === "" ? null : normalized;
26
30
  }
31
+ /**
32
+ * Rejection from {@link ConvaiClient.sendActionResultAndWait} after local
33
+ * validation succeeds. Inspect `reason` rather than parsing the message.
34
+ */
35
+ export class ActionResultAckError extends Error {
36
+ constructor(message, options) {
37
+ super(message);
38
+ this.name =
39
+ options.reason === "aborted"
40
+ ? "AbortError"
41
+ : options.reason === "timeout"
42
+ ? "TimeoutError"
43
+ : "ActionResultAckError";
44
+ this.reason = options.reason;
45
+ this.toolCallId = options.toolCallId;
46
+ this.errorCode = options.errorCode;
47
+ this.response = options.response;
48
+ this.cause = options.cause;
49
+ }
50
+ }
51
+ /**
52
+ * Marks a publication exchange failure that is safe to retry with the same
53
+ * logical connect attempt. The launch grant itself is single-use, so callers
54
+ * must never mint or submit a second grant for this recovery path.
55
+ */
56
+ class RetryablePublishedChatExchangeError extends Error {
57
+ }
27
58
  /**
28
59
  * Custom reconnect policy that disables automatic reconnection
29
60
  */
@@ -88,6 +119,12 @@ export class ConvaiClient extends EventEmitter {
88
119
  this._characterVersionManager = null;
89
120
  this._characterVersionManagerKey = null;
90
121
  this._characterSessionId = "-1";
122
+ /** Roster state for multi-character rooms. Empty for single-character sessions. */
123
+ this._roster = new CharacterRoster();
124
+ /** Roster commands awaiting their ack, keyed by command id. */
125
+ this._pendingRosterCommands = new Map();
126
+ this._rosterCommandSeq = 0;
127
+ this._requestTraceId = null;
91
128
  this._isBotReady = false;
92
129
  this._participantSid = "";
93
130
  this._storedConfig = null;
@@ -103,10 +140,12 @@ export class ConvaiClient extends EventEmitter {
103
140
  this._sseSession = null;
104
141
  this._logRtviMessages = true;
105
142
  this._isDisconnecting = false;
143
+ this._isPublishedChatSession = false;
106
144
  // RTVI client-ready handshake (race-proof bot-ready delivery)
107
145
  this._clientReadyRetryTimer = null;
108
146
  this._clientReadyTimeoutTimer = null;
109
147
  this._clientReadyMsgId = null;
148
+ this._pendingActionResultAcks = new Map();
110
149
  // Conversation session tracking
111
150
  this._conversationSessionId = 0;
112
151
  this._conversationStartTime = 0;
@@ -116,6 +155,23 @@ export class ConvaiClient extends EventEmitter {
116
155
  if (config) {
117
156
  this._storedConfig = config;
118
157
  this._logRtviMessages = config.logRtviMessages !== false;
158
+ // Derive the version readouts from the config eagerly, as
159
+ // characterVersions already is, so a React consumer destructuring
160
+ // characterReference off the hook sees the selector on first render
161
+ // rather than null until connect(). A malformed selector is left for
162
+ // connect() to reject -- the constructor has never thrown on config and
163
+ // starting to would be a behaviour change.
164
+ if (config.characterId && !isRosterConfig(config)) {
165
+ try {
166
+ const ref = resolveCharacterReference(config.characterId, config.characterVersion);
167
+ this._characterId = ref.characterId;
168
+ this._characterVersion = ref.version;
169
+ this._characterReference = ref.reference;
170
+ }
171
+ catch {
172
+ /* reported by connect() */
173
+ }
174
+ }
119
175
  }
120
176
  // Initialize room with no reconnect policy
121
177
  this._room = new Room({
@@ -133,6 +189,8 @@ export class ConvaiClient extends EventEmitter {
133
189
  endUserId: null,
134
190
  endUserMetadata: null,
135
191
  metrics: [],
192
+ characters: [],
193
+ activeMembershipId: null,
136
194
  disconnectReason: null,
137
195
  };
138
196
  // Default audio settings for optimal interruption handling
@@ -166,6 +224,9 @@ export class ConvaiClient extends EventEmitter {
166
224
  get connectionType() {
167
225
  return this._connectionType;
168
226
  }
227
+ get isPublishedChatSession() {
228
+ return this._isPublishedChatSession;
229
+ }
169
230
  get apiKey() {
170
231
  return this._apiKey;
171
232
  }
@@ -223,6 +284,48 @@ export class ConvaiClient extends EventEmitter {
223
284
  get characterSessionId() {
224
285
  return this._characterSessionId;
225
286
  }
287
+ on(event, callback) {
288
+ return super.on(event, callback);
289
+ }
290
+ /**
291
+ * Server-owned snapshot of a multi-character room, or null for a
292
+ * single-character session. Carries the room id, epochs and roster.
293
+ */
294
+ get roomSession() {
295
+ return this._roster.roomSession;
296
+ }
297
+ /**
298
+ * Character instances in a multi-character room, in roster order. Empty for a
299
+ * single-character session, so `length` distinguishes the two.
300
+ */
301
+ get characters() {
302
+ return this._roster.characters;
303
+ }
304
+ /** Membership currently receiving user turns, or null. */
305
+ get activeMembershipId() {
306
+ return this._roster.activeMembershipId;
307
+ }
308
+ /**
309
+ * Start or stop the server streaming per-turn usage/cost breakdowns as
310
+ * `usageUpdate` events. Informational only; server-side usage tracking and
311
+ * billing are unaffected either way.
312
+ */
313
+ toggleUsageUpdates(enabled) {
314
+ if (!this.isTransportReady())
315
+ return;
316
+ this.publishMessage("usage-toggle", { enabled });
317
+ }
318
+ /** Insert a transcript line locally. Nothing is sent; nothing is billed. */
319
+ appendMessage(message) {
320
+ if (!message?.content?.trim()) {
321
+ throw new Error("appendMessage requires non-empty content");
322
+ }
323
+ return this._messageHandler.appendLocalMessage(message);
324
+ }
325
+ /** Trace id of the /connect that produced this session; quote it to backend support. */
326
+ get requestTraceId() {
327
+ return this._requestTraceId;
328
+ }
226
329
  get isBotReady() {
227
330
  return this._isBotReady;
228
331
  }
@@ -249,12 +352,60 @@ export class ConvaiClient extends EventEmitter {
249
352
  */
250
353
  setupEventListeners() {
251
354
  // Room event listeners
355
+ // Per-membership audio binding. In a roster room every character is its
356
+ // own participant with identity `character:{membership_id}`; the guide's
357
+ // rule is to bind a track to the exact member or drop it, never to fall
358
+ // back to the active character. Single-character rooms keep the legacy
359
+ // behaviour: the track is surfaced with no attribution.
360
+ this._room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
361
+ if (track.kind !== "audio")
362
+ return;
363
+ const identity = participant.identity;
364
+ if (!this._roster.isMultiCharacter) {
365
+ this.emit("characterAudioTrack", {
366
+ membershipId: null,
367
+ characterId: this._characterId,
368
+ participantIdentity: identity,
369
+ track: track.mediaStreamTrack,
370
+ });
371
+ return;
372
+ }
373
+ const membershipId = identity.startsWith("character:")
374
+ ? identity.slice("character:".length)
375
+ : null;
376
+ const member = membershipId ? this._roster.find(membershipId) : undefined;
377
+ if (!member) {
378
+ // Unattributable in a roster room: quarantine rather than guess.
379
+ if (this._logRtviMessages) {
380
+ console.warn(`[ConvaiClient] dropped audio track from unattributed participant "${identity}"`);
381
+ }
382
+ return;
383
+ }
384
+ this.emit("characterAudioTrack", {
385
+ membershipId: member.membershipId,
386
+ characterId: member.characterId,
387
+ participantIdentity: identity,
388
+ track: track.mediaStreamTrack,
389
+ });
390
+ });
252
391
  this._room.on(RoomEvent.Disconnected, (reason) => {
253
392
  this._connectionStateHandler.handleDisconnected(reason);
254
393
  });
255
394
  this._room.on(RoomEvent.ConnectionStateChanged, this._connectionStateHandler.handleConnectionStateChanged.bind(this._connectionStateHandler));
256
395
  // Message handler events
257
- this._messageHandler.on("botReady", () => {
396
+ this._messageHandler.on("botReady", (about) => {
397
+ // Each roster member sends its own bot-ready. Feed the roster before the
398
+ // de-dupe below, which only guards the single consumer-facing event --
399
+ // otherwise every member after the first would be dropped.
400
+ if (this._roster.applyBotReady(about)) {
401
+ this.updateState({
402
+ characters: this._roster.characters,
403
+ activeMembershipId: this._roster.activeMembershipId,
404
+ });
405
+ const membershipId = about?.membership_id;
406
+ if (membershipId)
407
+ this.emit("characterReady", this._roster.find(membershipId));
408
+ }
258
409
  // Server may send multiple bot-ready frames (one per client-ready
259
410
  // we retried). Only surface the first to consumers per connect.
260
411
  if (this._isBotReady) {
@@ -265,8 +416,17 @@ export class ConvaiClient extends EventEmitter {
265
416
  this.updateState({ isConnected: true });
266
417
  this.emit("botReady");
267
418
  });
268
- this._messageHandler.on("messagesChange", (messages) => {
269
- this.emit("message", messages[messages.length - 1]);
419
+ this._messageHandler.on("messagesChange", (messages, updatedMessage, updatedMessages) => {
420
+ // Most updates target the tail. Correlated v2 chunks can update an
421
+ // earlier streaming row. A v2 stop can finalize several rows at once.
422
+ if (updatedMessages?.length) {
423
+ for (const message of updatedMessages) {
424
+ this.emit("message", message);
425
+ }
426
+ }
427
+ else {
428
+ this.emit("message", updatedMessage ?? messages[messages.length - 1]);
429
+ }
270
430
  this.emit("messagesChange", messages);
271
431
  });
272
432
  this._messageHandler.on("userTranscriptionChange", (transcription) => {
@@ -348,6 +508,9 @@ export class ConvaiClient extends EventEmitter {
348
508
  this._messageHandler.on("llmNoResponse", () => {
349
509
  this.emit("llmNoResponse");
350
510
  });
511
+ this._messageHandler.on("botTurnCompleted", (data) => {
512
+ this.emit("botTurnCompleted", data);
513
+ });
351
514
  // Forward bot-output event (aggregated output with spoken status)
352
515
  this._messageHandler.on("botOutput", (data) => {
353
516
  this.emit("botOutput", data);
@@ -373,7 +536,29 @@ export class ConvaiClient extends EventEmitter {
373
536
  this._messageHandler.on("actionResponse", (data) => {
374
537
  this.emit("actionResponse", data);
375
538
  });
539
+ this._messageHandler.on("usageUpdate", (data) => {
540
+ this.emit("usageUpdate", data);
541
+ });
542
+ this._messageHandler.on("characterStatus", (status) => {
543
+ if (this._roster.applyCharacterStatus(status)) {
544
+ this.updateState({
545
+ characters: this._roster.characters,
546
+ activeMembershipId: this._roster.activeMembershipId,
547
+ });
548
+ }
549
+ this.emit("characterStatus", status);
550
+ });
551
+ this._messageHandler.on("modelOutput", (data) => {
552
+ this.emit("modelOutput", data);
553
+ });
554
+ this._messageHandler.on("botLlmTextRaw", (data) => {
555
+ this.emit("botLlmTextRaw", data);
556
+ });
557
+ this._messageHandler.on("modelOutputProtocolError", (data) => {
558
+ this.emit("modelOutputProtocolError", data);
559
+ });
376
560
  this._messageHandler.on("serverResponse", (response) => {
561
+ this._handleRosterServerResponse(response);
377
562
  this.emit("serverResponse", response);
378
563
  });
379
564
  this._messageHandler.on("interactionCreated", (data) => {
@@ -437,6 +622,30 @@ export class ConvaiClient extends EventEmitter {
437
622
  this._room.localParticipant.publishData(encodedData, { reliable: true });
438
623
  }
439
624
  }
625
+ /**
626
+ * Publish exactly one message while retaining the transport's local completion
627
+ * signal. Server acceptance is still proved separately by `serverResponse`.
628
+ */
629
+ async publishMessageAndWaitForLocalPublication(type, data, id) {
630
+ const message = id === undefined ? { type, data } : { type, id, data };
631
+ if (this._activeTransport === "websocket") {
632
+ if (!this._wsSession?.isConnected) {
633
+ throw new Error("The action-result WebSocket transport is no longer available");
634
+ }
635
+ this._wsSession.sendMessage(type, data);
636
+ return;
637
+ }
638
+ if (this._activeTransport === "livekit" &&
639
+ this._room?.localParticipant &&
640
+ this._room.state !== "disconnected") {
641
+ const encodedData = new TextEncoder().encode(JSON.stringify(message));
642
+ await this._room.localParticipant.publishData(encodedData, {
643
+ reliable: true,
644
+ });
645
+ return;
646
+ }
647
+ throw new Error("The action-result transport is no longer available");
648
+ }
440
649
  /**
441
650
  * Returns true when a message can be sent on the active transport.
442
651
  */
@@ -460,13 +669,18 @@ export class ConvaiClient extends EventEmitter {
460
669
  }
461
670
  // Store config for reconnection
462
671
  this._storedConfig = { ...finalConfig };
672
+ this._isPublishedChatSession = false;
463
673
  // Use characterSessionId from config when reconnecting (so we resume the same session)
464
674
  if (finalConfig.characterSessionId &&
465
675
  finalConfig.characterSessionId !== "-1") {
466
676
  this._characterSessionId = finalConfig.characterSessionId;
467
677
  }
468
678
  const configWithDefaults = this.applyConfigDefaults(finalConfig);
679
+ // A fresh connect starts on the v1 model-output contract; the /connect
680
+ // response upgrades it once the server confirms the negotiated version.
681
+ this._messageHandler.setModelOutputVersion(1);
469
682
  this.updateState({ isConnecting: true });
683
+ this._requestTraceId = null;
470
684
  try {
471
685
  // Store connection config
472
686
  this._apiKey = configWithDefaults.apiKey ?? null;
@@ -520,6 +734,14 @@ export class ConvaiClient extends EventEmitter {
520
734
  enableEmotion: configWithDefaults.enableEmotion,
521
735
  emotionConfig: configWithDefaults.emotionConfig,
522
736
  actionConfig: configWithDefaults.actionConfig,
737
+ capabilities: configWithDefaults.capabilities,
738
+ dynamicInfo: configWithDefaults.dynamicInfo,
739
+ keepInContext: configWithDefaults.keepInContext,
740
+ narrativeTemplateKeys: configWithDefaults.narrativeTemplateKeys,
741
+ sceneDescription: configWithDefaults.sceneDescription,
742
+ respondModes: configWithDefaults.respondModes,
743
+ useResponseLifecycleContract: configWithDefaults.useResponseLifecycleContract,
744
+ invocationMetadata: configWithDefaults.invocationMetadata,
523
745
  debug: configWithDefaults.debug,
524
746
  onMessage: (payload) => this._messageHandler.handleDataReceivedPublic(payload),
525
747
  onError: (error) => this.emit("error", error),
@@ -539,8 +761,12 @@ export class ConvaiClient extends EventEmitter {
539
761
  }
540
762
  // Prepare request body with required parameters
541
763
  const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
764
+ const isRoster = isRosterConfig(configWithDefaults);
765
+ const serializedCapabilities = serializeProtocolCapabilities(configWithDefaults.capabilities);
542
766
  const requestBody = {
543
- character_id: configWithDefaults.characterReference,
767
+ ...(isRoster
768
+ ? buildRosterConnectFields(configWithDefaults)
769
+ : { character_id: configWithDefaults.characterReference }),
544
770
  ...(stateOfMind ? { state_of_mind: stateOfMind } : {}),
545
771
  ...(configWithDefaults.endUserId && {
546
772
  end_user_id: configWithDefaults.endUserId,
@@ -549,9 +775,11 @@ export class ConvaiClient extends EventEmitter {
549
775
  end_user_metadata: configWithDefaults.endUserMetadata,
550
776
  }),
551
777
  transport: transportType,
778
+ ...(serializedCapabilities && {
779
+ capabilities: serializedCapabilities,
780
+ }),
552
781
  connection_type: connType,
553
782
  ...buildBlendshapeConnectConfig(configWithDefaults),
554
- llm_provider: "dynamic",
555
783
  ...(buildEmotionConnectConfig(configWithDefaults) && {
556
784
  emotion_config: buildEmotionConnectConfig(configWithDefaults),
557
785
  }),
@@ -562,10 +790,20 @@ export class ConvaiClient extends EventEmitter {
562
790
  ...(effectiveVisionInputConfig && {
563
791
  vision_input_config: serializeVisionInputConfig(effectiveVisionInputConfig),
564
792
  }),
793
+ ...(configWithDefaults.visionPromptMode !== undefined && {
794
+ vision_prompt_mode: configWithDefaults.visionPromptMode,
795
+ }),
565
796
  ...(configWithDefaults.respondModes && {
566
797
  respond_modes: this.serializeRespondModes(configWithDefaults.respondModes),
567
798
  }),
568
- ...(characterSessionIdToSend &&
799
+ ...(serializedCapabilities && { capabilities: serializedCapabilities }),
800
+ ...(configWithDefaults.sceneDescription && {
801
+ scene_description: configWithDefaults.sceneDescription,
802
+ }),
803
+ // A roster room owns its members' sessions; the singular resume id is
804
+ // a compatibility mirror and must not go back on the wire.
805
+ ...(!isRoster &&
806
+ characterSessionIdToSend &&
569
807
  characterSessionIdToSend !== "-1" && {
570
808
  character_session_id: characterSessionIdToSend,
571
809
  }),
@@ -587,6 +825,12 @@ export class ConvaiClient extends EventEmitter {
587
825
  ...(configWithDefaults.debug !== undefined && {
588
826
  debug: configWithDefaults.debug,
589
827
  }),
828
+ ...(configWithDefaults.preserveLinksInOutput !== undefined && {
829
+ preserve_links_in_output: configWithDefaults.preserveLinksInOutput,
830
+ }),
831
+ ...(configWithDefaults.useResponseLifecycleContract !== undefined && {
832
+ use_response_lifecycle_contract: configWithDefaults.useResponseLifecycleContract,
833
+ }),
590
834
  invocation_metadata: {
591
835
  source: configWithDefaults.invocationMetadata?.source ?? "web_sdk",
592
836
  client_version: configWithDefaults.invocationMetadata?.clientVersion ??
@@ -605,41 +849,62 @@ export class ConvaiClient extends EventEmitter {
605
849
  else if (configWithDefaults.apiKey) {
606
850
  connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
607
851
  }
608
- // HTTP POST /connect — same request for both transports
609
- const response = await fetch(`${configWithDefaults.url}/connect`, {
610
- method: "POST",
611
- headers: connectHeaders,
612
- body: JSON.stringify(requestBody),
613
- });
614
- if (!response.ok) {
615
- const errorText = await response.text();
616
- let errorMessage = `HTTP ${response.status}`;
617
- try {
618
- const errorData = JSON.parse(errorText);
619
- const raw = errorData.message ?? errorData.error ?? errorData.detail;
620
- if (typeof raw === "string") {
621
- errorMessage = raw;
622
- }
623
- else if (Array.isArray(raw)) {
624
- // FastAPI/Pydantic 422 detail is an array of validation error objects
625
- errorMessage = raw
626
- .map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
627
- .join("; ") || errorMessage;
852
+ // HTTP POST /connect — same request for both transports. A roster room
853
+ // may still be provisioning from an identical concurrent create, which
854
+ // the runtime answers with a retryable 409; the wrapper replays the
855
+ // identical payload and is a straight pass-through otherwise.
856
+ const connectOnce = async () => {
857
+ const response = await fetch(`${configWithDefaults.url}/connect`, {
858
+ method: "POST",
859
+ headers: connectHeaders,
860
+ body: JSON.stringify(requestBody),
861
+ });
862
+ if (!response.ok) {
863
+ const errorText = await response.text();
864
+ let errorMessage = `HTTP ${response.status}`;
865
+ try {
866
+ const errorData = JSON.parse(errorText);
867
+ const raw = errorData.message ?? errorData.error ?? errorData.detail;
868
+ if (typeof raw === "string") {
869
+ errorMessage = raw;
870
+ }
871
+ else if (Array.isArray(raw)) {
872
+ // FastAPI/Pydantic 422 — detail is an array of validation error objects
873
+ errorMessage = raw
874
+ .map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
875
+ .join("; ") || errorMessage;
876
+ }
877
+ else if (raw != null) {
878
+ errorMessage = JSON.stringify(raw);
879
+ }
880
+ else if (errorText) {
881
+ errorMessage = `${errorMessage}: ${errorText}`;
882
+ }
628
883
  }
629
- else if (raw != null) {
630
- errorMessage = JSON.stringify(raw);
884
+ catch {
885
+ if (errorText)
886
+ errorMessage = `${errorMessage}: ${errorText}`;
631
887
  }
632
- else if (errorText) {
633
- errorMessage = `${errorMessage}: ${errorText}`;
888
+ // A roster room's failures are named and machine-actionable, and the
889
+ // trace id is what backend support correlates on, so they get a typed
890
+ // error. Single-character behaviour is unchanged.
891
+ if (isRoster) {
892
+ let parsed = errorText;
893
+ try {
894
+ parsed = JSON.parse(errorText);
895
+ }
896
+ catch {
897
+ /* keep the raw text as the detail */
898
+ }
899
+ throw roomErrorFromResponse(response.status, parsed);
634
900
  }
901
+ throw new Error(errorMessage);
635
902
  }
636
- catch {
637
- if (errorText)
638
- errorMessage = `${errorMessage}: ${errorText}`;
639
- }
640
- throw new Error(errorMessage);
641
- }
642
- const connectionData = await response.json();
903
+ return await response.json();
904
+ };
905
+ const connectionData = isRoster
906
+ ? await this._withProvisioningRetry(configWithDefaults, connectOnce)
907
+ : await connectOnce();
643
908
  return await this.consumeConnectionData(connectionData, configWithDefaults);
644
909
  }
645
910
  catch (error) {
@@ -672,6 +937,10 @@ export class ConvaiClient extends EventEmitter {
672
937
  * await client.connectWithConnectionData(data)
673
938
  */
674
939
  async connectWithConnectionData(data, config) {
940
+ // A client can be reused after a published session. This is a normal
941
+ // credentialed/proxy connection path, so never leave published-only UI
942
+ // semantics (fresh grant requirement, text-only controls) enabled.
943
+ this._isPublishedChatSession = false;
675
944
  const finalConfig = config || this._storedConfig;
676
945
  if (!finalConfig) {
677
946
  throw new Error("No configuration provided. Pass config to connectWithConnectionData() or store it in the client");
@@ -703,6 +972,274 @@ export class ConvaiClient extends EventEmitter {
703
972
  throw error;
704
973
  }
705
974
  }
975
+ /** Correlate roster acks back to the promise that issued the command. */
976
+ _handleRosterServerResponse(response) {
977
+ const eventType = response.event_type;
978
+ if (eventType !== "interaction-target" &&
979
+ eventType !== "character-roster-update") {
980
+ return;
981
+ }
982
+ const extras = response.extras ?? null;
983
+ const commandId = extras?.command_id;
984
+ const pending = commandId
985
+ ? this._pendingRosterCommands.get(commandId)
986
+ : undefined;
987
+ if (response.status === "error") {
988
+ // Adopt the server's epochs even from a rejection -- a stale-epoch error
989
+ // carries the authoritative value, and that is what makes a retry
990
+ // meaningful.
991
+ this._roster.adoptEpochs(extras);
992
+ this._pushRosterState();
993
+ if (pending && commandId) {
994
+ this._pendingRosterCommands.delete(commandId);
995
+ clearTimeout(pending.timer);
996
+ pending.reject(Object.assign(new Error(response.message ?? `${eventType} was rejected`), { code: extras?.code ?? null, extras }));
997
+ }
998
+ return;
999
+ }
1000
+ // Apply the change whether or not this client asked for it: another
1001
+ // participant's switch reaches us through the same event.
1002
+ let change;
1003
+ if (eventType === "interaction-target") {
1004
+ change = this._roster.applyInteractionTarget(extras);
1005
+ this._pushRosterState();
1006
+ this.emit("activeCharacterChange", change);
1007
+ }
1008
+ else {
1009
+ change = this._roster.applyRosterUpdate(extras);
1010
+ this._pushRosterState();
1011
+ this.emit("rosterChange", change);
1012
+ }
1013
+ if (pending && commandId) {
1014
+ this._pendingRosterCommands.delete(commandId);
1015
+ clearTimeout(pending.timer);
1016
+ pending.resolve(change);
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Settle every in-flight roster command.
1021
+ *
1022
+ * Without this a caller who disconnects mid-command keeps a live 10s timer
1023
+ * and gets a rejection long after the session ended -- an unhandled rejection
1024
+ * if they stopped awaiting when they tore the client down. The roster is
1025
+ * reset alongside, so the command could not be applied meaningfully anyway.
1026
+ */
1027
+ _failPendingRosterCommands(reason) {
1028
+ for (const [, pending] of this._pendingRosterCommands) {
1029
+ clearTimeout(pending.timer);
1030
+ pending.reject(new Error(reason));
1031
+ }
1032
+ this._pendingRosterCommands.clear();
1033
+ }
1034
+ _pushRosterState() {
1035
+ this.updateState({
1036
+ characters: this._roster.characters,
1037
+ activeMembershipId: this._roster.activeMembershipId,
1038
+ });
1039
+ }
1040
+ /** Publish a roster command and settle when its ack arrives. */
1041
+ _sendRosterCommand(type, build) {
1042
+ if (!this._roster.isMultiCharacter) {
1043
+ return Promise.reject(new Error(`Cannot send ${type}: this session was not created with a characters array`));
1044
+ }
1045
+ if (!this.isTransportReady()) {
1046
+ return Promise.reject(new Error(`Cannot send ${type}: not connected`));
1047
+ }
1048
+ const commandId = `${type}-${++this._rosterCommandSeq}-${Date.now()}`;
1049
+ return new Promise((resolve, reject) => {
1050
+ const timer = setTimeout(() => {
1051
+ this._pendingRosterCommands.delete(commandId);
1052
+ reject(new Error(`${type} was not acknowledged within ${ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS}ms. ` +
1053
+ `The outcome is unknown -- acks can be suppressed as duplicates. ` +
1054
+ `Reconcile from the next authoritative room state before sending a conflicting command.`));
1055
+ }, ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS);
1056
+ this._pendingRosterCommands.set(commandId, { resolve, reject, timer });
1057
+ this.publishMessage(type, build(commandId), commandId);
1058
+ });
1059
+ }
1060
+ /**
1061
+ * Route subsequent user turns to a different character instance.
1062
+ *
1063
+ * Pass null to close every gate, so no member consumes the user until a
1064
+ * target is set again.
1065
+ */
1066
+ async setInteractionTarget(membershipId) {
1067
+ if (!this._roster.isMultiCharacter) {
1068
+ throw new Error("Cannot send interaction-target: this session was not created with a characters array");
1069
+ }
1070
+ if (membershipId !== null) {
1071
+ if (!membershipId) {
1072
+ throw new Error("setInteractionTarget requires a membershipId, or null to close all gates");
1073
+ }
1074
+ const instance = this._roster.find(membershipId);
1075
+ if (this._roster.isMultiCharacter && !instance) {
1076
+ throw new Error(`Unknown membershipId "${membershipId}". Address instances by membershipId, not characterId.`);
1077
+ }
1078
+ if (instance && !instance.isReady) {
1079
+ throw new Error(`Membership "${membershipId}" has not sent bot-ready yet; the runtime refuses an unready target`);
1080
+ }
1081
+ }
1082
+ const send = () => this._sendRosterCommand("interaction-target", (commandId) => this._roster.buildInteractionTarget(membershipId, commandId));
1083
+ try {
1084
+ return (await send());
1085
+ }
1086
+ catch (error) {
1087
+ // Another participant switched first. The roster adopted the server's
1088
+ // epoch when the rejection arrived, so exactly one retry is now fenced
1089
+ // correctly; a second would mean we are losing a race we should report.
1090
+ if (error?.code !== "stale_route_epoch")
1091
+ throw error;
1092
+ return (await send());
1093
+ }
1094
+ }
1095
+ /** Add or remove character instances on a live room, without reconnecting. */
1096
+ async updateCharacterRoster(options) {
1097
+ // Report the wrong-session-shape first. Otherwise a single-character
1098
+ // client, whose roster is empty, trips the "cannot be emptied" rule below
1099
+ // and gets an error about a room it never had.
1100
+ if (!this._roster.isMultiCharacter) {
1101
+ throw new Error("Cannot send character-roster-update: this session was not created with a characters array");
1102
+ }
1103
+ const add = options?.add ?? [];
1104
+ const remove = options?.remove ?? [];
1105
+ if (add.length === 0 && remove.length === 0) {
1106
+ throw new Error("updateCharacterRoster requires at least one add or remove");
1107
+ }
1108
+ if (add.some((c) => !c?.characterId?.trim())) {
1109
+ throw new Error("Every entry in add requires a characterId");
1110
+ }
1111
+ if (remove.length && remove.length >= this._roster.characters.length && add.length === 0) {
1112
+ throw new Error("A roster room cannot be emptied; add a replacement or keep one member");
1113
+ }
1114
+ // Removing the active member needs an explicit, ready replacement --
1115
+ // the runtime answers `replacement_target_required` otherwise, and
1116
+ // `replacement_target_unavailable` for one that has not sent bot-ready.
1117
+ const active = this._roster.activeMembershipId;
1118
+ const effective = !options.replacementTarget && active && remove.includes(active)
1119
+ ? { ...options, replacementTarget: this._roster.pickReplacement(remove) }
1120
+ : options;
1121
+ return (await this._sendRosterCommand("character-roster-update", (commandId) => this._roster.buildRosterUpdate(effective, commandId)));
1122
+ }
1123
+ /**
1124
+ * Run a room request, retrying only the conflicts that mean "still working".
1125
+ *
1126
+ * The runtime answers a create that collides with an in-flight identical
1127
+ * create with 409 `ROSTER_PROVISIONING_IN_PROGRESS`, and the documented
1128
+ * recovery is to retry the same key with bounded backoff. Left to callers,
1129
+ * every integration reimplements that, usually without a cap. The payload is
1130
+ * replayed verbatim -- changing it would turn the retry into a fingerprint
1131
+ * mismatch.
1132
+ */
1133
+ async _withProvisioningRetry(config, run) {
1134
+ const setting = config.rosterProvisioningRetry;
1135
+ if (setting === false)
1136
+ return run();
1137
+ const attempts = Math.max(1, setting?.attempts ?? ConvaiClient.ROSTER_RETRY_ATTEMPTS);
1138
+ const initialDelayMs = Math.max(0, setting?.initialDelayMs ?? ConvaiClient.ROSTER_RETRY_INITIAL_MS);
1139
+ let lastError;
1140
+ for (let attempt = 0; attempt < attempts; attempt++) {
1141
+ try {
1142
+ return await run();
1143
+ }
1144
+ catch (error) {
1145
+ lastError = error;
1146
+ const retryable = error instanceof ConvaiRoomError && error.isRetryable;
1147
+ if (!retryable || attempt === attempts - 1)
1148
+ throw error;
1149
+ // Jitter so a lobby of clients waking on the same room does not
1150
+ // retry in lockstep.
1151
+ const base = initialDelayMs * 2 ** attempt;
1152
+ const jittered = base * (0.8 + Math.random() * 0.4);
1153
+ await new Promise((resolve) => setTimeout(resolve, jittered));
1154
+ }
1155
+ }
1156
+ throw lastError;
1157
+ }
1158
+ /**
1159
+ * Join a multi-character room that already exists.
1160
+ *
1161
+ * A join supplies a locator and the joining human, and nothing else: the
1162
+ * roster is server-owned, so resending topology is rejected. It never spawns,
1163
+ * removes or repairs a character -- only room creation does that.
1164
+ *
1165
+ * Capacity counts distinct speakers derived from `endUserId`, and defaults to
1166
+ * one, so raise `maxNumParticipants` at creation before expecting a second
1167
+ * human to get in.
1168
+ *
1169
+ * @example
1170
+ * ```ts
1171
+ * const client = new ConvaiClient({ apiKey: 'YOUR_API_KEY' })
1172
+ * await client.joinRoom({
1173
+ * roomSessionId: 'the-room-session-id',
1174
+ * endUserId: 'player-99',
1175
+ * })
1176
+ * ```
1177
+ */
1178
+ async joinRoom(options) {
1179
+ validateJoinOptions(options);
1180
+ // joinRoom is a credentialed Workspace connection, not a publication.
1181
+ // Reset this before further validation so a rejected normal attempt also
1182
+ // cannot leave a reused client in published-session mode.
1183
+ this._isPublishedChatSession = false;
1184
+ const stored = this._storedConfig ?? {};
1185
+ const url = stored.url ?? "https://realtime-api.convai.com";
1186
+ if (!stored.apiKey && !stored.authToken) {
1187
+ throw new Error("Either apiKey or authToken is required to join a room");
1188
+ }
1189
+ this._logRtviMessages = stored.logRtviMessages !== false;
1190
+ this._messageHandler.setRtviMessageLogging(this._logRtviMessages);
1191
+ this.updateState({ isConnecting: true });
1192
+ try {
1193
+ const connectionData = await this._withProvisioningRetry(stored, async () => {
1194
+ const response = await fetch(`${url}/connect`, {
1195
+ method: "POST",
1196
+ headers: {
1197
+ "Content-Type": "application/json",
1198
+ ...(stored.apiKey
1199
+ ? { "X-API-Key": stored.apiKey }
1200
+ : { "API-AUTH-TOKEN": stored.authToken }),
1201
+ },
1202
+ body: JSON.stringify(buildJoinRequestBody(options)),
1203
+ });
1204
+ if (!response.ok) {
1205
+ const text = await response.text();
1206
+ let parsed = text;
1207
+ try {
1208
+ parsed = JSON.parse(text);
1209
+ }
1210
+ catch {
1211
+ /* keep the raw text as the detail */
1212
+ }
1213
+ throw roomErrorFromResponse(response.status, parsed);
1214
+ }
1215
+ return (await response.json());
1216
+ });
1217
+ // The join response carries the persisted roster, so the joining client
1218
+ // learns the topology it never sent.
1219
+ const rosterCharacters = connectionData.characters;
1220
+ const initialCharacterId = rosterCharacters?.[0]?.character_id;
1221
+ const joinConfig = {
1222
+ ...stored,
1223
+ characters: undefined,
1224
+ characterId: initialCharacterId ?? stored.characterId,
1225
+ endUserId: options.endUserId,
1226
+ ...(options.endUserMetadata
1227
+ ? { endUserMetadata: options.endUserMetadata }
1228
+ : {}),
1229
+ };
1230
+ this._storedConfig = joinConfig;
1231
+ const configWithDefaults = this.applyConfigDefaults(joinConfig, {
1232
+ requireCredential: false,
1233
+ });
1234
+ return await this.consumeConnectionData(connectionData, configWithDefaults);
1235
+ }
1236
+ catch (error) {
1237
+ await this.cleanupWebSocketSessionAfterConnectFailure();
1238
+ this.updateState({ isConnected: false, isConnecting: false });
1239
+ this.emit("error", error);
1240
+ throw error;
1241
+ }
1242
+ }
706
1243
  /**
707
1244
  * Add default URL, apply RTVI-logging config, and validate that either an
708
1245
  * API key or auth token plus a character ID are present.
@@ -727,9 +1264,27 @@ export class ConvaiClient extends EventEmitter {
727
1264
  const hasApiKey = Boolean(configWithDefaults.apiKey);
728
1265
  const hasAuthToken = Boolean(configWithDefaults.authToken);
729
1266
  const missingCredential = requireCredential && !hasApiKey && !hasAuthToken;
730
- if (missingCredential || !configWithDefaults.characterId) {
1267
+ // Runs for every topology: the v2 protocol constraints rule out
1268
+ // sharedSessionKey and extra human capacity in single-character rooms too.
1269
+ validateProtocolTopology(configWithDefaults);
1270
+ const roster = isRosterConfig(configWithDefaults);
1271
+ if (missingCredential || (!roster && !configWithDefaults.characterId)) {
731
1272
  throw new Error("Either apiKey or authToken is required, and characterId is required");
732
1273
  }
1274
+ // A roster names its members individually; every roster rule is checked
1275
+ // before a request is built.
1276
+ if (roster) {
1277
+ validateRosterConfig(configWithDefaults);
1278
+ const initial = configWithDefaults.characters[0].characterId.trim();
1279
+ return {
1280
+ ...configWithDefaults,
1281
+ // Singular readouts mirror characters[0], exactly as the wire's own
1282
+ // top-level session_id/character_session_id aliases do.
1283
+ characterId: initial,
1284
+ characterVersion: null,
1285
+ characterReference: initial,
1286
+ };
1287
+ }
733
1288
  // Split the character id from its version selector. The bare UUID names
734
1289
  // the character everywhere inside the SDK (memory, character info); the
735
1290
  // joined reference is what the runtime receives.
@@ -790,6 +1345,24 @@ export class ConvaiClient extends EventEmitter {
790
1345
  characterSessionId: connectionData.character_session_id,
791
1346
  };
792
1347
  }
1348
+ // The server answers a capability request with what it actually selected.
1349
+ // A silent downgrade would leave the client parsing v2 frames as v1 (or the
1350
+ // reverse), so each selection is validated and the model-output version the
1351
+ // message handler decodes is set from the server's answer, not the request.
1352
+ const selectedCapabilities = connectionData.capabilities ?? {};
1353
+ const requestedCapabilities = configWithDefaults.capabilities;
1354
+ validateActionProtocolSelection(requestedCapabilities?.actionProtocolVersion, selectedCapabilities.action_protocol_version);
1355
+ this._messageHandler.setModelOutputVersion(validateModelOutputVersionSelection(requestedCapabilities?.modelOutputVersion, selectedCapabilities.model_output_version));
1356
+ this._requestTraceId =
1357
+ connectionData.request_trace_id ?? null;
1358
+ // Seed the roster before the transport comes up, so the first bot-ready and
1359
+ // any early roster change already have instances to match against.
1360
+ if (this._roster.applyConnectResponse(connectionData)) {
1361
+ this.updateState({
1362
+ characters: this._roster.characters,
1363
+ activeMembershipId: this._roster.activeMembershipId,
1364
+ });
1365
+ }
793
1366
  if (connectionData.end_user_id) {
794
1367
  this._endUserId = connectionData.end_user_id;
795
1368
  }
@@ -963,6 +1536,189 @@ export class ConvaiClient extends EventEmitter {
963
1536
  }
964
1537
  this.clearAllManagers();
965
1538
  }
1539
+ /**
1540
+ * Connect to a published text-chat revision without publisher credentials.
1541
+ *
1542
+ * The launch grant is single-use and is never retained for reconnect. This
1543
+ * path is LiveKit data-only: it does not enable microphone, camera, TTS,
1544
+ * video, or lipsync.
1545
+ */
1546
+ async connectWithPublicationGrant(launchToken, options = {}) {
1547
+ if (typeof launchToken !== "string" ||
1548
+ launchToken.length < 32 ||
1549
+ launchToken.length > 512) {
1550
+ throw new Error("A valid publication launch token is required");
1551
+ }
1552
+ if (this._state.isConnected || this._state.isConnecting) {
1553
+ throw new Error("Disconnect the current session before connecting again");
1554
+ }
1555
+ const throwIfAborted = () => {
1556
+ if (options.signal?.aborted) {
1557
+ throw new DOMException("The operation was aborted", "AbortError");
1558
+ }
1559
+ };
1560
+ throwIfAborted();
1561
+ let abortDisconnect = null;
1562
+ const disconnectOnAbort = () => {
1563
+ abortDisconnect ?? (abortDisconnect = this.disconnect().catch(() => undefined));
1564
+ };
1565
+ const apiUrl = (options.url ?? "https://realtime-api.convai.com").replace(/\/+$/, "");
1566
+ const connectAttemptId = options.connectAttemptId ??
1567
+ (typeof crypto !== "undefined" && "randomUUID" in crypto
1568
+ ? `published_${crypto.randomUUID().replace(/-/g, "")}`
1569
+ : `published_${Date.now()}_${Math.random().toString(36).slice(2)}`);
1570
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(connectAttemptId)) {
1571
+ throw new Error("A valid published chat connect attempt id is required");
1572
+ }
1573
+ this._storedConfig = null;
1574
+ this._isPublishedChatSession = true;
1575
+ this._requestTraceId = null;
1576
+ this._apiKey = null;
1577
+ this._authToken = null;
1578
+ this._characterId = null;
1579
+ this._characterSessionId = "-1";
1580
+ this._endUserId = null;
1581
+ this._endUserMetadata = null;
1582
+ this._requestTraceId = null;
1583
+ this._memoryManager = null;
1584
+ this._connectionType = "text";
1585
+ this._activeTransport = "livekit";
1586
+ // Published chat does not negotiate protocol capabilities. A client may
1587
+ // have previously connected to a v2 character, so restore the default v1
1588
+ // handling before accepting this text-only session's legacy envelopes.
1589
+ this._messageHandler.setModelOutputVersion(1);
1590
+ this.updateState({ isConnecting: true });
1591
+ try {
1592
+ options.signal?.addEventListener("abort", disconnectOnAbort, {
1593
+ once: true,
1594
+ });
1595
+ const exchangeGrant = () => fetch(`${apiUrl}/published-chat/connect`, {
1596
+ method: "POST",
1597
+ // A published launch token is bearer-like. Never include any ambient
1598
+ // cookies when the host happens to be same-origin with Core.
1599
+ credentials: "omit",
1600
+ headers: { "Content-Type": "application/json" },
1601
+ body: JSON.stringify({
1602
+ launch_token: launchToken,
1603
+ connect_attempt_id: connectAttemptId,
1604
+ }),
1605
+ signal: options.signal,
1606
+ });
1607
+ const readConnectionResponse = async (response, retryTransientFailure) => {
1608
+ if (!response.ok) {
1609
+ if (retryTransientFailure && response.status >= 500) {
1610
+ throw new RetryablePublishedChatExchangeError();
1611
+ }
1612
+ const errorText = await response.text();
1613
+ let errorMessage = `HTTP ${response.status}`;
1614
+ try {
1615
+ const errorData = JSON.parse(errorText);
1616
+ this._requestTraceId =
1617
+ errorData.request_trace_id ?? errorData.requestTraceId ?? null;
1618
+ const raw = errorData.message ?? errorData.error ?? errorData.detail;
1619
+ errorMessage =
1620
+ typeof raw === "string"
1621
+ ? raw
1622
+ : raw == null
1623
+ ? errorMessage
1624
+ : JSON.stringify(raw);
1625
+ }
1626
+ catch {
1627
+ if (errorText)
1628
+ errorMessage = `${errorMessage}: ${errorText}`;
1629
+ }
1630
+ throw new Error(errorMessage);
1631
+ }
1632
+ let connectionData;
1633
+ try {
1634
+ connectionData = await response.json();
1635
+ }
1636
+ catch {
1637
+ if (retryTransientFailure) {
1638
+ throw new RetryablePublishedChatExchangeError();
1639
+ }
1640
+ throw new Error("Published chat returned an invalid connection response");
1641
+ }
1642
+ throwIfAborted();
1643
+ if (typeof connectionData !== "object" ||
1644
+ connectionData === null ||
1645
+ typeof connectionData.room_url !== "string" ||
1646
+ typeof connectionData.token !== "string" ||
1647
+ typeof connectionData.character_session_id !==
1648
+ "string") {
1649
+ if (retryTransientFailure) {
1650
+ throw new RetryablePublishedChatExchangeError();
1651
+ }
1652
+ throw new Error("Published chat returned an invalid connection response");
1653
+ }
1654
+ return connectionData;
1655
+ };
1656
+ const requestConnection = async (retryTransientFailure) => {
1657
+ let response;
1658
+ try {
1659
+ response = await exchangeGrant();
1660
+ }
1661
+ catch (error) {
1662
+ throwIfAborted();
1663
+ if (retryTransientFailure) {
1664
+ throw new RetryablePublishedChatExchangeError();
1665
+ }
1666
+ throw error;
1667
+ }
1668
+ return readConnectionResponse(response, retryTransientFailure);
1669
+ };
1670
+ let connectionData;
1671
+ try {
1672
+ connectionData = await requestConnection(true);
1673
+ }
1674
+ catch (error) {
1675
+ throwIfAborted();
1676
+ if (!(error instanceof RetryablePublishedChatExchangeError)) {
1677
+ throw error;
1678
+ }
1679
+ // A response can be lost or corrupted after Core consumed the
1680
+ // single-use grant. Retry exactly once with the same attempt key;
1681
+ // Core/Character API replay that logical attempt instead of creating
1682
+ // a second session.
1683
+ connectionData = await requestConnection(false);
1684
+ }
1685
+ throwIfAborted();
1686
+ this._requestTraceId = connectionData.request_trace_id ?? null;
1687
+ this._characterSessionId = connectionData.character_session_id;
1688
+ this._endUserId = connectionData.end_user_id ?? null;
1689
+ this._endUserMetadata = connectionData.end_user_metadata ?? null;
1690
+ await this._room.connect(connectionData.room_url, connectionData.token, {
1691
+ rtcConfig: {
1692
+ // Hosted Core has TURN, so relay is the safe default. On-prem
1693
+ // deployments without TURN may explicitly allow host/srflx paths.
1694
+ iceTransportPolicy: options.iceTransportPolicy ?? "relay",
1695
+ },
1696
+ });
1697
+ throwIfAborted();
1698
+ this._audioManager.syncStateFromRoom({ emit: true });
1699
+ this._participantSid = this._room.localParticipant.sid;
1700
+ this.updateState({
1701
+ isConnected: true,
1702
+ isConnecting: false,
1703
+ endUserId: this._endUserId,
1704
+ endUserMetadata: this._endUserMetadata,
1705
+ });
1706
+ this.emit("connect");
1707
+ this._startClientReadyHandshake();
1708
+ }
1709
+ catch (error) {
1710
+ if (options.signal?.aborted) {
1711
+ abortDisconnect ?? (abortDisconnect = this.disconnect().catch(() => undefined));
1712
+ await abortDisconnect;
1713
+ }
1714
+ this.updateState({ isConnected: false, isConnecting: false });
1715
+ this.emit("error", error);
1716
+ throw error;
1717
+ }
1718
+ finally {
1719
+ options.signal?.removeEventListener("abort", disconnectOnAbort);
1720
+ }
1721
+ }
966
1722
  /**
967
1723
  * RTVI client-ready handshake. Server-side gates bot-ready emission on
968
1724
  * receipt of client-ready (and re-emits bot-ready on every client-ready),
@@ -1027,6 +1783,10 @@ export class ConvaiClient extends EventEmitter {
1027
1783
  */
1028
1784
  async disconnect() {
1029
1785
  this._stopClientReadyHandshake();
1786
+ // Unconditional: the branches below each depend on a transport being
1787
+ // matched, and none of them runs when there is nothing to tear down. A
1788
+ // command in flight must still be settled either way.
1789
+ this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
1030
1790
  if (this._activeTransport === "sse" && this._sseSession) {
1031
1791
  await this._sseSession.disconnect();
1032
1792
  this._connectionStateHandler.resetConnectionState();
@@ -1059,6 +1819,15 @@ export class ConvaiClient extends EventEmitter {
1059
1819
  this.clearAllManagers();
1060
1820
  }
1061
1821
  }
1822
+ else if (this._state.isConnected || this._state.isConnecting) {
1823
+ // No transport matched, but the client believes it is (or is becoming)
1824
+ // connected -- a connect() that failed after flipping isConnecting, or
1825
+ // was torn down underneath us. Without this branch disconnect() was a
1826
+ // silent no-op here and the flags stayed stuck, so a UI bound to
1827
+ // stateChange kept showing a session that did not exist.
1828
+ this._connectionStateHandler.resetConnectionState();
1829
+ this.clearAllManagers();
1830
+ }
1062
1831
  }
1063
1832
  /**
1064
1833
  * Clear all manager states
@@ -1067,6 +1836,8 @@ export class ConvaiClient extends EventEmitter {
1067
1836
  this._connectionType = null;
1068
1837
  this._activeTransport = null;
1069
1838
  this._wsSession = null;
1839
+ this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
1840
+ this._roster.reset();
1070
1841
  this._sseSession = null;
1071
1842
  this._apiKey = null;
1072
1843
  this._authToken = null;
@@ -1095,6 +1866,13 @@ export class ConvaiClient extends EventEmitter {
1095
1866
  * Reset the session ID to start a new conversation
1096
1867
  */
1097
1868
  resetSession() {
1869
+ // In a roster room the singular session id is a compatibility mirror of
1870
+ // characters[0], and character sessions are server-owned -- there is no
1871
+ // client-side reset for them. Throwing names the situation; a silent no-op
1872
+ // would look like it worked.
1873
+ if (this._roster.isMultiCharacter) {
1874
+ throw new Error("resetSession() is not supported in a multi-character room; character sessions are server-owned");
1875
+ }
1098
1876
  this._characterSessionId = "-1";
1099
1877
  this.emit("characterSessionId", "-1");
1100
1878
  this._messageHandler.reset();
@@ -1136,15 +1914,28 @@ export class ConvaiClient extends EventEmitter {
1136
1914
  const stateOfMind = normalizeStateOfMind(options.stateOfMind) ?? null;
1137
1915
  if (stateOfMind)
1138
1916
  this._stateOfMind = stateOfMind;
1139
- if (stateOfMind && this._activeTransport === "sse") {
1140
- this._sseSession?.sendMessages([
1141
- { type: "update-emotion", data: { state_of_mind: stateOfMind } },
1142
- {
1143
- type: "user_text_message",
1144
- data: userTextMessage,
1145
- ...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
1146
- },
1147
- ]);
1917
+ if (this._activeTransport === "sse") {
1918
+ const requestedDynamicInfo = options.dynamicInfo?.trim();
1919
+ if (requestedDynamicInfo) {
1920
+ this._sseSession?.updateDynamicInfo(requestedDynamicInfo);
1921
+ }
1922
+ const messages = [];
1923
+ const dynamicInfo = this._sseSession?.getDynamicInfo()?.trim();
1924
+ if (dynamicInfo) {
1925
+ messages.push({
1926
+ type: "update-dynamic-info",
1927
+ data: { dynamic_info: { text: dynamicInfo } },
1928
+ });
1929
+ }
1930
+ if (stateOfMind) {
1931
+ messages.push({ type: "update-emotion", data: { state_of_mind: stateOfMind } });
1932
+ }
1933
+ messages.push({
1934
+ type: "user_text_message",
1935
+ data: userTextMessage,
1936
+ ...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
1937
+ });
1938
+ this._sseSession?.sendMessages(messages);
1148
1939
  }
1149
1940
  else {
1150
1941
  if (stateOfMind) {
@@ -1155,11 +1946,167 @@ export class ConvaiClient extends EventEmitter {
1155
1946
  }
1156
1947
  this.publishMessage("user_text_message", userTextMessage, logicalTurnId);
1157
1948
  }
1949
+ // The submitted line is appended locally on every transport, so it shows
1950
+ // without waiting for a server echo. Sessions that do echo it back as a
1951
+ // final user-transcription are deduplicated in the message handler.
1952
+ this._messageHandler.addUserTextMessage(text);
1158
1953
  }
1159
1954
  catch (error) {
1160
1955
  throw error;
1161
1956
  }
1162
1957
  }
1958
+ /**
1959
+ * Return a correlated terminal result for a client-executed tool call.
1960
+ */
1961
+ sendActionResult(result) {
1962
+ const payload = this.buildActionResultPayload(result);
1963
+ this.publishMessage("action-result", payload);
1964
+ }
1965
+ /**
1966
+ * Publish one correlated terminal result and wait for Core's exact
1967
+ * `server-response` acknowledgement. This method performs one publication;
1968
+ * callers own any idempotent retry policy.
1969
+ */
1970
+ async sendActionResultAndWait(result, options = {}) {
1971
+ const timeoutMs = options.timeoutMs ?? ConvaiClient.ACTION_RESULT_ACK_TIMEOUT_MS;
1972
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1973
+ throw new Error("Action result acknowledgement timeout must be greater than zero");
1974
+ }
1975
+ if (options.signal?.aborted) {
1976
+ throw new ActionResultAckError("Action result acknowledgement was aborted", {
1977
+ reason: "aborted",
1978
+ toolCallId: result.id,
1979
+ });
1980
+ }
1981
+ const payload = this.buildActionResultPayload(result);
1982
+ const toolCallId = payload.id;
1983
+ if (this._pendingActionResultAcks.has(toolCallId)) {
1984
+ throw new ActionResultAckError(`An action result acknowledgement is already pending for ${toolCallId}`, {
1985
+ reason: "duplicate_in_flight",
1986
+ toolCallId,
1987
+ });
1988
+ }
1989
+ const reservation = Symbol(toolCallId);
1990
+ this._pendingActionResultAcks.set(toolCallId, reservation);
1991
+ return new Promise((resolve, reject) => {
1992
+ let settled = false;
1993
+ let timeout = null;
1994
+ let unsubscribe = null;
1995
+ const cleanup = () => {
1996
+ if (timeout !== null) {
1997
+ clearTimeout(timeout);
1998
+ timeout = null;
1999
+ }
2000
+ if (unsubscribe) {
2001
+ unsubscribe();
2002
+ unsubscribe = null;
2003
+ }
2004
+ options.signal?.removeEventListener("abort", handleAbort);
2005
+ if (this._pendingActionResultAcks.get(toolCallId) === reservation) {
2006
+ this._pendingActionResultAcks.delete(toolCallId);
2007
+ }
2008
+ };
2009
+ const resolveOnce = (ack) => {
2010
+ if (settled)
2011
+ return;
2012
+ settled = true;
2013
+ cleanup();
2014
+ resolve(ack);
2015
+ };
2016
+ const rejectOnce = (error) => {
2017
+ if (settled)
2018
+ return;
2019
+ settled = true;
2020
+ cleanup();
2021
+ reject(error);
2022
+ };
2023
+ const handleAbort = () => {
2024
+ rejectOnce(new ActionResultAckError("Action result acknowledgement was aborted", {
2025
+ reason: "aborted",
2026
+ toolCallId,
2027
+ }));
2028
+ };
2029
+ const handleServerResponse = (response) => {
2030
+ if (!response || typeof response !== "object")
2031
+ return;
2032
+ const candidate = response;
2033
+ if (candidate.event_type !== "action-result")
2034
+ return;
2035
+ const extras = candidate.extras;
2036
+ if (!extras || extras.tool_call_id !== toolCallId)
2037
+ return;
2038
+ const message = typeof candidate.message === "string" ? candidate.message : null;
2039
+ if (candidate.status === "success") {
2040
+ resolveOnce({
2041
+ toolCallId,
2042
+ status: "success",
2043
+ message,
2044
+ idempotent: extras.idempotent === true,
2045
+ });
2046
+ return;
2047
+ }
2048
+ if (candidate.status !== "error")
2049
+ return;
2050
+ const errorCode = typeof extras.error_code === "string" ? extras.error_code : undefined;
2051
+ rejectOnce(new ActionResultAckError(message ?? errorCode ?? "Core rejected the action result", {
2052
+ reason: "server_error",
2053
+ toolCallId,
2054
+ ...(errorCode ? { errorCode } : {}),
2055
+ response: candidate,
2056
+ }));
2057
+ };
2058
+ // Register every terminal observer before publication so a synchronous
2059
+ // WebSocket acknowledgement cannot outrun its waiter.
2060
+ unsubscribe = this.on("serverResponse", handleServerResponse);
2061
+ options.signal?.addEventListener("abort", handleAbort, { once: true });
2062
+ timeout = setTimeout(() => {
2063
+ rejectOnce(new ActionResultAckError(`Timed out waiting for action result acknowledgement for ${toolCallId}`, {
2064
+ reason: "timeout",
2065
+ toolCallId,
2066
+ }));
2067
+ }, timeoutMs);
2068
+ void this.publishMessageAndWaitForLocalPublication("action-result", payload).catch((cause) => {
2069
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
2070
+ rejectOnce(new ActionResultAckError(`Failed to publish action result ${toolCallId}${detail}`, {
2071
+ reason: "publication_error",
2072
+ toolCallId,
2073
+ cause,
2074
+ }));
2075
+ });
2076
+ });
2077
+ }
2078
+ buildActionResultPayload(result) {
2079
+ if (!this.isTransportReady()) {
2080
+ throw new Error("Cannot send an action result before the session is ready");
2081
+ }
2082
+ if (!result.id.trim())
2083
+ throw new Error("Action result id is required");
2084
+ if (result.status !== "completed" &&
2085
+ result.status !== "error" &&
2086
+ result.status !== "cancelled") {
2087
+ throw new Error("Unsupported action result status");
2088
+ }
2089
+ if (result.status === "completed" && result.error !== undefined) {
2090
+ throw new Error("Completed action results cannot include error");
2091
+ }
2092
+ if (result.output !== undefined && !isJsonValue(result.output)) {
2093
+ throw new Error("Action result output must contain only finite JSON values");
2094
+ }
2095
+ if (result.error !== undefined && !isJsonValue(result.error)) {
2096
+ throw new Error("Action result error must contain only finite JSON values");
2097
+ }
2098
+ const characterSessionId = result.characterSessionId ?? this._characterSessionId;
2099
+ return {
2100
+ id: result.id,
2101
+ status: result.status,
2102
+ ...(result.output !== undefined && { output: result.output }),
2103
+ ...(result.error !== undefined && { error: result.error }),
2104
+ ...(characterSessionId &&
2105
+ characterSessionId !== "-1" && {
2106
+ character_session_id: characterSessionId,
2107
+ }),
2108
+ };
2109
+ }
1163
2110
  /**
1164
2111
  * Send a trigger message to invoke specific character actions
1165
2112
  */
@@ -1260,8 +2207,16 @@ export class ConvaiClient extends EventEmitter {
1260
2207
  updateDynamicInfo(dynamicInfo) {
1261
2208
  if (!this.isTransportReady() || !dynamicInfo?.trim())
1262
2209
  return;
2210
+ const nextDynamicInfo = dynamicInfo.trim();
2211
+ if (this._storedConfig) {
2212
+ this._storedConfig = { ...this._storedConfig, dynamicInfo: nextDynamicInfo };
2213
+ }
2214
+ if (this._activeTransport === "sse" && this._sseSession) {
2215
+ this._sseSession.updateDynamicInfo(nextDynamicInfo);
2216
+ return;
2217
+ }
1263
2218
  this.publishMessage("update-dynamic-info", {
1264
- dynamic_info: { text: dynamicInfo },
2219
+ dynamic_info: { text: nextDynamicInfo },
1265
2220
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
1266
2221
  });
1267
2222
  }
@@ -1309,6 +2264,22 @@ export class ConvaiClient extends EventEmitter {
1309
2264
  if (shouldPreemptLipsync) {
1310
2265
  this.startTrackedConversation(`[context-update:${options.mode ?? "append"}]`);
1311
2266
  }
2267
+ if (this._activeTransport === "sse" && this._sseSession) {
2268
+ const data = {
2269
+ ...(hasText && { text: options.text.trim() }),
2270
+ ...(options.mode && { mode: options.mode }),
2271
+ ...(options.run_llm && { run_llm: options.run_llm }),
2272
+ ...(options.respond_mode && { respond_mode: options.respond_mode }),
2273
+ ...(hasAttention && { current_attention_object: options.current_attention_object }),
2274
+ };
2275
+ if (options.run_llm === "false") {
2276
+ this._sseSession.queueMessage("context-update", data);
2277
+ }
2278
+ else {
2279
+ this._sseSession.sendMessage("context-update", data);
2280
+ }
2281
+ return;
2282
+ }
1312
2283
  this.publishMessage("context-update", {
1313
2284
  ...(hasText && { text: options.text.trim() }),
1314
2285
  ...(options.mode && { mode: options.mode }),
@@ -1370,6 +2341,8 @@ export class ConvaiClient extends EventEmitter {
1370
2341
  }
1371
2342
  if (!this.isTransportReady())
1372
2343
  return;
2344
+ if (this._activeTransport === "sse")
2345
+ return;
1373
2346
  this.publishMessage("update-emotion", {
1374
2347
  state_of_mind: normalized,
1375
2348
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
@@ -1464,4 +2437,18 @@ export class ConvaiClient extends EventEmitter {
1464
2437
  }
1465
2438
  ConvaiClient.CLIENT_READY_RETRY_MS = 500;
1466
2439
  ConvaiClient.CLIENT_READY_TIMEOUT_MS = 45000;
2440
+ ConvaiClient.ACTION_RESULT_ACK_TIMEOUT_MS = 10000;
2441
+ /**
2442
+ * How long to wait for a roster command's ack.
2443
+ *
2444
+ * A timeout is *not* a failure. Every character participant observes the
2445
+ * broadcast and duplicate forwards are response-suppressed after one actor
2446
+ * answers, so a lost ack is an expected outcome. The contract's rule is to
2447
+ * treat it as unknown and reconcile from the next authoritative snapshot,
2448
+ * which is what the rejection below tells the caller to do.
2449
+ */
2450
+ ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS = 10000;
2451
+ /** Default bounded retry while a roster room is still provisioning. */
2452
+ ConvaiClient.ROSTER_RETRY_ATTEMPTS = 4;
2453
+ ConvaiClient.ROSTER_RETRY_INITIAL_MS = 500;
1467
2454
  //# sourceMappingURL=ConvaiClient.js.map