@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.71

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 (178) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +30 -7
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +298 -87
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/types.js +19 -0
  40. package/dist/locus-info/types.js.map +1 -1
  41. package/dist/media/index.js +3 -1
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/properties.js +1 -0
  44. package/dist/media/properties.js.map +1 -1
  45. package/dist/meeting/in-meeting-actions.js +3 -1
  46. package/dist/meeting/in-meeting-actions.js.map +1 -1
  47. package/dist/meeting/index.js +1046 -689
  48. package/dist/meeting/index.js.map +1 -1
  49. package/dist/meeting/muteState.js +10 -1
  50. package/dist/meeting/muteState.js.map +1 -1
  51. package/dist/meeting/request.js +5 -2
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/util.js +20 -2
  54. package/dist/meeting/util.js.map +1 -1
  55. package/dist/meeting-info/meeting-info-v2.js +2 -2
  56. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  57. package/dist/meetings/index.js +231 -78
  58. package/dist/meetings/index.js.map +1 -1
  59. package/dist/meetings/meetings.types.js +6 -1
  60. package/dist/meetings/meetings.types.js.map +1 -1
  61. package/dist/meetings/request.js +39 -0
  62. package/dist/meetings/request.js.map +1 -1
  63. package/dist/meetings/util.js +79 -5
  64. package/dist/meetings/util.js.map +1 -1
  65. package/dist/member/index.js +10 -0
  66. package/dist/member/index.js.map +1 -1
  67. package/dist/member/types.js.map +1 -1
  68. package/dist/member/util.js +3 -0
  69. package/dist/member/util.js.map +1 -1
  70. package/dist/metrics/constants.js +4 -1
  71. package/dist/metrics/constants.js.map +1 -1
  72. package/dist/multistream/codec/constants.js +63 -0
  73. package/dist/multistream/codec/constants.js.map +1 -0
  74. package/dist/multistream/mediaRequestManager.js +62 -15
  75. package/dist/multistream/mediaRequestManager.js.map +1 -1
  76. package/dist/multistream/receiveSlot.js +9 -0
  77. package/dist/multistream/receiveSlot.js.map +1 -1
  78. package/dist/reactions/reactions.type.js.map +1 -1
  79. package/dist/recording-controller/index.js +1 -3
  80. package/dist/recording-controller/index.js.map +1 -1
  81. package/dist/types/config.d.ts +2 -0
  82. package/dist/types/constants.d.ts +9 -1
  83. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  84. package/dist/types/controls-options-manager/index.d.ts +10 -0
  85. package/dist/types/hashTree/constants.d.ts +2 -0
  86. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  87. package/dist/types/hashTree/utils.d.ts +18 -0
  88. package/dist/types/index.d.ts +3 -0
  89. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  90. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  91. package/dist/types/locus-info/index.d.ts +50 -6
  92. package/dist/types/locus-info/types.d.ts +21 -1
  93. package/dist/types/media/properties.d.ts +1 -0
  94. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  95. package/dist/types/meeting/index.d.ts +78 -5
  96. package/dist/types/meeting/request.d.ts +1 -0
  97. package/dist/types/meeting/util.d.ts +8 -0
  98. package/dist/types/meetings/index.d.ts +30 -2
  99. package/dist/types/meetings/meetings.types.d.ts +15 -0
  100. package/dist/types/meetings/request.d.ts +14 -0
  101. package/dist/types/member/index.d.ts +1 -0
  102. package/dist/types/member/types.d.ts +1 -0
  103. package/dist/types/member/util.d.ts +1 -0
  104. package/dist/types/metrics/constants.d.ts +3 -0
  105. package/dist/types/multistream/codec/constants.d.ts +7 -0
  106. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  107. package/dist/types/reactions/reactions.type.d.ts +3 -0
  108. package/dist/webinar/index.js +305 -159
  109. package/dist/webinar/index.js.map +1 -1
  110. package/package.json +22 -22
  111. package/src/aiEnableRequest/index.ts +16 -0
  112. package/src/breakouts/breakout.ts +3 -1
  113. package/src/breakouts/index.ts +31 -0
  114. package/src/config.ts +2 -0
  115. package/src/constants.ts +13 -2
  116. package/src/controls-options-manager/constants.ts +14 -1
  117. package/src/controls-options-manager/index.ts +47 -24
  118. package/src/controls-options-manager/util.ts +81 -1
  119. package/src/hashTree/constants.ts +16 -0
  120. package/src/hashTree/hashTreeParser.ts +580 -196
  121. package/src/hashTree/utils.ts +36 -0
  122. package/src/index.ts +6 -0
  123. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  124. package/src/interceptors/locusRetry.ts +25 -4
  125. package/src/interpretation/index.ts +27 -9
  126. package/src/interpretation/interpretation.types.ts +11 -0
  127. package/src/locus-info/controlsUtils.ts +3 -1
  128. package/src/locus-info/index.ts +293 -97
  129. package/src/locus-info/types.ts +25 -1
  130. package/src/media/index.ts +3 -0
  131. package/src/media/properties.ts +1 -0
  132. package/src/meeting/in-meeting-actions.ts +4 -0
  133. package/src/meeting/index.ts +386 -48
  134. package/src/meeting/muteState.ts +10 -1
  135. package/src/meeting/request.ts +11 -0
  136. package/src/meeting/util.ts +21 -2
  137. package/src/meeting-info/meeting-info-v2.ts +4 -2
  138. package/src/meetings/index.ts +134 -44
  139. package/src/meetings/meetings.types.ts +19 -0
  140. package/src/meetings/request.ts +43 -0
  141. package/src/meetings/util.ts +97 -1
  142. package/src/member/index.ts +10 -0
  143. package/src/member/types.ts +1 -0
  144. package/src/member/util.ts +3 -0
  145. package/src/metrics/constants.ts +3 -0
  146. package/src/multistream/codec/constants.ts +58 -0
  147. package/src/multistream/mediaRequestManager.ts +119 -28
  148. package/src/multistream/receiveSlot.ts +18 -0
  149. package/src/reactions/reactions.type.ts +3 -0
  150. package/src/recording-controller/index.ts +1 -2
  151. package/src/webinar/index.ts +214 -36
  152. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  153. package/test/unit/spec/breakouts/breakout.ts +9 -3
  154. package/test/unit/spec/breakouts/index.ts +49 -0
  155. package/test/unit/spec/controls-options-manager/index.js +140 -29
  156. package/test/unit/spec/controls-options-manager/util.js +165 -0
  157. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  158. package/test/unit/spec/hashTree/utils.ts +125 -1
  159. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  160. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  161. package/test/unit/spec/interpretation/index.ts +26 -4
  162. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  163. package/test/unit/spec/locus-info/index.js +487 -81
  164. package/test/unit/spec/media/index.ts +31 -0
  165. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  166. package/test/unit/spec/meeting/index.js +1240 -37
  167. package/test/unit/spec/meeting/muteState.js +81 -0
  168. package/test/unit/spec/meeting/request.js +12 -0
  169. package/test/unit/spec/meeting/utils.js +33 -0
  170. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  171. package/test/unit/spec/meetings/index.js +360 -10
  172. package/test/unit/spec/meetings/request.js +141 -0
  173. package/test/unit/spec/meetings/utils.js +189 -0
  174. package/test/unit/spec/member/index.js +7 -0
  175. package/test/unit/spec/member/util.js +24 -0
  176. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  177. package/test/unit/spec/recording-controller/index.js +9 -8
  178. package/test/unit/spec/webinar/index.ts +329 -28
@@ -34,6 +34,9 @@ import {
34
34
  ONLINE,
35
35
  OFFLINE,
36
36
  ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT,
37
+ LOCUS_LLM_EVENT,
38
+ LLM_PRACTICE_SESSION,
39
+ RECORDING_STATE,
37
40
  } from '@webex/plugin-meetings/src/constants';
38
41
  import {
39
42
  ConnectionState,
@@ -266,6 +269,20 @@ describe('plugin-meetings', () => {
266
269
  stopReachability: sinon.stub(),
267
270
  isSubnetReachable: sinon.stub().returns(true),
268
271
  };
272
+ webex.internal.llm.resolveSessionOwnership = sinon
273
+ .stub()
274
+ .callsFake((ownerMeetingId, sessionId) => {
275
+ const currentOwner = webex.internal.llm.getOwnerMeetingId
276
+ ? webex.internal.llm.getOwnerMeetingId(sessionId)
277
+ : undefined;
278
+ const canAssertOwnership = !!ownerMeetingId;
279
+
280
+ return {
281
+ currentOwner,
282
+ canAssertOwnership,
283
+ isOwner: !currentOwner || !canAssertOwnership || currentOwner === ownerMeetingId,
284
+ };
285
+ });
269
286
  webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
270
287
  webex.internal.llm.on = sinon.stub();
271
288
  webex.internal.voicea.announce = sinon.stub();
@@ -417,6 +434,160 @@ describe('plugin-meetings', () => {
417
434
  assert.instanceOf(meeting.mediaRequestManagers.screenShareVideo, MediaRequestManager);
418
435
  });
419
436
 
437
+ it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 when sending multistream video requests', () => {
438
+ const getIngressPayloadType = sinon.stub().returns(97);
439
+
440
+ meeting.isMultistream = true;
441
+ meeting.mediaProperties.webrtcMediaConnection = {
442
+ getIngressPayloadType,
443
+ requestMedia: sinon.stub(),
444
+ };
445
+
446
+ const fakeReceiveSlot = {
447
+ on: sinon.stub(),
448
+ off: sinon.stub(),
449
+ sourceState: 'live',
450
+ mediaType: MediaType.VideoMain,
451
+ wcmeReceiveSlot: {id: 'fake-wcme-slot'},
452
+ };
453
+
454
+ meeting.mediaRequestManagers.video.addRequest(
455
+ {
456
+ policyInfo: {
457
+ policy: 'receiver-selected',
458
+ csi: 42,
459
+ },
460
+ receiveSlots: [fakeReceiveSlot],
461
+ codecInfo: {
462
+ codec: 'h264',
463
+ maxFs: 3600,
464
+ },
465
+ },
466
+ true
467
+ );
468
+
469
+ assert.calledOnceWithExactly(
470
+ getIngressPayloadType,
471
+ MediaType.VideoMain,
472
+ MediaCodecMimeType.H264
473
+ );
474
+ });
475
+
476
+ it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 and AV1 for slides video when AV1 slides support is enabled', () => {
477
+ const localWebex = new MockWebex({
478
+ children: {
479
+ meetings: Meetings,
480
+ credentials: Credentials,
481
+ support: Support,
482
+ llm: LLM,
483
+ mercury: Mercury,
484
+ },
485
+ config: {
486
+ credentials: {
487
+ client_id: 'mock-client-id',
488
+ },
489
+ meetings: {
490
+ reconnection: {
491
+ enabled: false,
492
+ },
493
+ mediaSettings: {},
494
+ metrics: {},
495
+ stats: {},
496
+ experimental: {enableUnifiedMeetings: true},
497
+ degradationPreferences: {maxMacroblocksLimit: 8192},
498
+ enableAv1SlidesSupport: true,
499
+ },
500
+ metrics: {
501
+ type: ['behavioral'],
502
+ },
503
+ },
504
+ });
505
+
506
+ localWebex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub();
507
+ localWebex.internal.newMetrics.callDiagnosticMetrics.clearEventLimitsForCorrelationId =
508
+ sinon.stub();
509
+ localWebex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve());
510
+ localWebex.internal.services = {get: sinon.stub().returns('locus-url')};
511
+ localWebex.credentials.getOrgId = sinon.stub().returns('fake-org-id');
512
+ localWebex.internal.metrics.submitClientMetrics = sinon.stub().returns(Promise.resolve());
513
+ localWebex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
514
+ localWebex.meetings.reachability = {
515
+ isAnyPublicClusterReachable: sinon.stub().resolves(true),
516
+ getReachabilityResults: sinon.stub().resolves(undefined),
517
+ getReachabilityMetrics: sinon.stub().resolves({}),
518
+ stopReachability: sinon.stub(),
519
+ isSubnetReachable: sinon.stub().returns(true),
520
+ };
521
+ localWebex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
522
+ localWebex.internal.llm.on = sinon.stub();
523
+ localWebex.internal.voicea.announce = sinon.stub();
524
+ localWebex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
525
+ {},
526
+ {parent: localWebex}
527
+ );
528
+
529
+ Metrics.initialSetup(localWebex);
530
+
531
+ const localMeeting = new Meeting(
532
+ {
533
+ userId: uuid1,
534
+ resource: uuid2,
535
+ deviceUrl: uuid3,
536
+ locus: {url: url1},
537
+ destination: testDestination,
538
+ destinationType: DESTINATION_TYPE.MEETING_ID,
539
+ correlationId,
540
+ selfId: uuid1,
541
+ },
542
+ {
543
+ parent: localWebex,
544
+ }
545
+ );
546
+
547
+ const getIngressPayloadType = sinon.stub().callsFake((_mediaType, codecMimeType) => {
548
+ if (codecMimeType === MediaCodecMimeType.H264) {
549
+ return 97;
550
+ }
551
+ if (codecMimeType === MediaCodecMimeType.AV1) {
552
+ return 98;
553
+ }
554
+
555
+ return undefined;
556
+ });
557
+
558
+ localMeeting.isMultistream = true;
559
+ localMeeting.mediaProperties.webrtcMediaConnection = {
560
+ getIngressPayloadType,
561
+ requestMedia: sinon.stub(),
562
+ };
563
+
564
+ const fakeReceiveSlot = {
565
+ on: sinon.stub(),
566
+ off: sinon.stub(),
567
+ sourceState: 'live',
568
+ mediaType: MediaType.VideoSlides,
569
+ wcmeReceiveSlot: {id: 'fake-wcme-slides-slot'},
570
+ };
571
+
572
+ localMeeting.mediaRequestManagers.screenShareVideo.addRequest(
573
+ {
574
+ policyInfo: {
575
+ policy: 'receiver-selected',
576
+ csi: 42,
577
+ },
578
+ receiveSlots: [fakeReceiveSlot],
579
+ codecInfo: {
580
+ codec: 'h264',
581
+ maxFs: 3600,
582
+ },
583
+ },
584
+ true
585
+ );
586
+
587
+ assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.H264);
588
+ assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.AV1);
589
+ });
590
+
420
591
  it('uses meeting id as correlation id if not provided in constructor', () => {
421
592
  const newMeeting = new Meeting(
422
593
  {
@@ -1977,6 +2148,113 @@ describe('plugin-meetings', () => {
1977
2148
  fakeProcessedReaction
1978
2149
  );
1979
2150
  });
2151
+
2152
+ [
2153
+ {
2154
+ title: 'should skip a reaction when the default relay route does not match the LLM binding',
2155
+ isPracticeSessionConnected: false,
2156
+ route: 'wrong-default-route',
2157
+ defaultBinding: 'default-route',
2158
+ practiceBinding: 'practice-route',
2159
+ shouldProcess: false,
2160
+ expectedSessionLabel: 'default session',
2161
+ },
2162
+ {
2163
+ title: 'should process a reaction when the default relay route matches the LLM binding',
2164
+ isPracticeSessionConnected: false,
2165
+ route: 'default-route',
2166
+ defaultBinding: 'default-route',
2167
+ practiceBinding: 'practice-route',
2168
+ shouldProcess: true,
2169
+ },
2170
+ {
2171
+ title:
2172
+ 'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
2173
+ isPracticeSessionConnected: true,
2174
+ route: 'practice-route',
2175
+ defaultBinding: 'default-route',
2176
+ practiceBinding: 'practice-route',
2177
+ shouldProcess: true,
2178
+ },
2179
+ {
2180
+ title:
2181
+ 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
2182
+ isPracticeSessionConnected: true,
2183
+ route: 'default-route',
2184
+ defaultBinding: 'default-route',
2185
+ practiceBinding: 'practice-route',
2186
+ shouldProcess: false,
2187
+ expectedSessionLabel: 'practice session',
2188
+ },
2189
+ ].forEach(
2190
+ ({
2191
+ title,
2192
+ isPracticeSessionConnected,
2193
+ route,
2194
+ defaultBinding,
2195
+ practiceBinding,
2196
+ shouldProcess,
2197
+ expectedSessionLabel,
2198
+ }) => {
2199
+ it(title, () => {
2200
+ meeting.isReactionsSupported = sinon.stub().returns(true);
2201
+ meeting.config.receiveReactions = true;
2202
+ const fakeSendersName = 'Fake reactors name';
2203
+ meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
2204
+ webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
2205
+ return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
2206
+ });
2207
+ webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
2208
+ if (llmSessionId === LLM_PRACTICE_SESSION) {
2209
+ return practiceBinding;
2210
+ }
2211
+
2212
+ return defaultBinding;
2213
+ });
2214
+ const fakeReactionPayload = {
2215
+ type: 'fake_type',
2216
+ codepoints: 'fake_codepoints',
2217
+ shortcodes: 'fake_shortcodes',
2218
+ };
2219
+ const fakeSenderPayload = {
2220
+ participantId: 'fake_participant_id',
2221
+ };
2222
+ const fakeRelayEvent = {
2223
+ headers: {route},
2224
+ data: {
2225
+ relayType: REACTION_RELAY_TYPES.REACTION,
2226
+ reaction: fakeReactionPayload,
2227
+ sender: fakeSenderPayload,
2228
+ },
2229
+ };
2230
+ const fakeProcessedReaction = {
2231
+ reaction: fakeReactionPayload,
2232
+ sender: {
2233
+ id: fakeSenderPayload.participantId,
2234
+ name: fakeSendersName,
2235
+ },
2236
+ };
2237
+
2238
+ TriggerProxy.trigger.resetHistory();
2239
+ meeting.processRelayEvent(fakeRelayEvent);
2240
+
2241
+ if (shouldProcess) {
2242
+ assert.calledWith(
2243
+ TriggerProxy.trigger,
2244
+ sinon.match.instanceOf(Meeting),
2245
+ {
2246
+ file: 'meeting/index',
2247
+ function: 'join',
2248
+ },
2249
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
2250
+ fakeProcessedReaction
2251
+ );
2252
+ } else {
2253
+ assert.notCalled(TriggerProxy.trigger);
2254
+ }
2255
+ });
2256
+ }
2257
+ );
1980
2258
  });
1981
2259
 
1982
2260
  describe('#handleLLMOnline', () => {
@@ -2016,6 +2294,14 @@ describe('plugin-meetings', () => {
2016
2294
 
2017
2295
  assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2018
2296
  });
2297
+
2298
+ it('calls syncAllHashTreeDatasets on locusInfo', () => {
2299
+ sinon.stub(meeting.locusInfo, 'syncAllHashTreeDatasets').resolves();
2300
+
2301
+ meeting.handleLLMOnline();
2302
+
2303
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
2304
+ });
2019
2305
  });
2020
2306
 
2021
2307
  describe('#join', () => {
@@ -4534,6 +4820,297 @@ describe('plugin-meetings', () => {
4534
4820
  },
4535
4821
  });
4536
4822
  });
4823
+
4824
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
4825
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
4826
+ const fakeStats = new Map([
4827
+ [
4828
+ 'transport-1',
4829
+ {
4830
+ type: 'transport',
4831
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4832
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4833
+ },
4834
+ ],
4835
+ [
4836
+ 'outbound-rtp-1',
4837
+ {
4838
+ type: 'outbound-rtp',
4839
+ ssrc: 12345,
4840
+ },
4841
+ ],
4842
+ ]);
4843
+
4844
+ statsAnalyzerStub.emit(
4845
+ {file: 'test', function: 'test'},
4846
+ StatsAnalyzerEventNames.STATS_UPDATE,
4847
+ {stats: fakeStats}
4848
+ );
4849
+
4850
+ assert.calledWith(
4851
+ TriggerProxy.trigger,
4852
+ sinon.match.instanceOf(Meeting),
4853
+ {
4854
+ file: 'meeting/index',
4855
+ function: 'setupStatsAnalyzerEventHandlers',
4856
+ },
4857
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4858
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4859
+ );
4860
+
4861
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4862
+ });
4863
+
4864
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
4865
+ const firstStats = new Map([
4866
+ [
4867
+ 'transport-1',
4868
+ {
4869
+ type: 'transport',
4870
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4871
+ },
4872
+ ],
4873
+ ]);
4874
+
4875
+ statsAnalyzerStub.emit(
4876
+ {file: 'test', function: 'test'},
4877
+ StatsAnalyzerEventNames.STATS_UPDATE,
4878
+ {stats: firstStats}
4879
+ );
4880
+
4881
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4882
+
4883
+ const secondStats = new Map([
4884
+ [
4885
+ 'transport-1',
4886
+ {
4887
+ type: 'transport',
4888
+ srtpCipher: 'AEAD_AES_256_GCM',
4889
+ },
4890
+ ],
4891
+ ]);
4892
+
4893
+ TriggerProxy.trigger.resetHistory();
4894
+
4895
+ statsAnalyzerStub.emit(
4896
+ {file: 'test', function: 'test'},
4897
+ StatsAnalyzerEventNames.STATS_UPDATE,
4898
+ {stats: secondStats}
4899
+ );
4900
+
4901
+ assert.calledWith(
4902
+ TriggerProxy.trigger,
4903
+ sinon.match.instanceOf(Meeting),
4904
+ {
4905
+ file: 'meeting/index',
4906
+ function: 'setupStatsAnalyzerEventHandlers',
4907
+ },
4908
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4909
+ {srtpCipher: 'AEAD_AES_256_GCM'}
4910
+ );
4911
+
4912
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
4913
+ });
4914
+
4915
+ it('does not emit event when srtpCipher has not changed', async () => {
4916
+ const firstStats = new Map([
4917
+ [
4918
+ 'transport-1',
4919
+ {
4920
+ type: 'transport',
4921
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4922
+ },
4923
+ ],
4924
+ ]);
4925
+
4926
+ statsAnalyzerStub.emit(
4927
+ {file: 'test', function: 'test'},
4928
+ StatsAnalyzerEventNames.STATS_UPDATE,
4929
+ {stats: firstStats}
4930
+ );
4931
+
4932
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4933
+
4934
+ TriggerProxy.trigger.resetHistory();
4935
+
4936
+ // Emit same cipher again
4937
+ statsAnalyzerStub.emit(
4938
+ {file: 'test', function: 'test'},
4939
+ StatsAnalyzerEventNames.STATS_UPDATE,
4940
+ {stats: firstStats}
4941
+ );
4942
+
4943
+ // Should not trigger event again
4944
+ assert.neverCalledWith(
4945
+ TriggerProxy.trigger,
4946
+ sinon.match.instanceOf(Meeting),
4947
+ sinon.match.any,
4948
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4949
+ sinon.match.any
4950
+ );
4951
+
4952
+ // Cipher should remain the same
4953
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4954
+ });
4955
+
4956
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
4957
+ const fakeStats = new Map([
4958
+ [
4959
+ 'outbound-rtp-1',
4960
+ {
4961
+ type: 'outbound-rtp',
4962
+ ssrc: 12345,
4963
+ },
4964
+ ],
4965
+ [
4966
+ 'inbound-rtp-1',
4967
+ {
4968
+ type: 'inbound-rtp',
4969
+ ssrc: 67890,
4970
+ },
4971
+ ],
4972
+ ]);
4973
+
4974
+ statsAnalyzerStub.emit(
4975
+ {file: 'test', function: 'test'},
4976
+ StatsAnalyzerEventNames.STATS_UPDATE,
4977
+ {stats: fakeStats}
4978
+ );
4979
+
4980
+ assert.neverCalledWith(
4981
+ TriggerProxy.trigger,
4982
+ sinon.match.instanceOf(Meeting),
4983
+ sinon.match.any,
4984
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4985
+ sinon.match.any
4986
+ );
4987
+
4988
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4989
+ });
4990
+
4991
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
4992
+ const fakeStats = new Map([
4993
+ [
4994
+ 'transport-1',
4995
+ {
4996
+ type: 'transport',
4997
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4998
+ // no srtpCipher property
4999
+ },
5000
+ ],
5001
+ ]);
5002
+
5003
+ statsAnalyzerStub.emit(
5004
+ {file: 'test', function: 'test'},
5005
+ StatsAnalyzerEventNames.STATS_UPDATE,
5006
+ {stats: fakeStats}
5007
+ );
5008
+
5009
+ assert.neverCalledWith(
5010
+ TriggerProxy.trigger,
5011
+ sinon.match.instanceOf(Meeting),
5012
+ sinon.match.any,
5013
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5014
+ sinon.match.any
5015
+ );
5016
+
5017
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5018
+ });
5019
+
5020
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
5021
+ const fakeStats = new Map([
5022
+ [
5023
+ 'transport-1',
5024
+ {
5025
+ type: 'transport',
5026
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5027
+ },
5028
+ ],
5029
+ [
5030
+ 'transport-2',
5031
+ {
5032
+ type: 'transport',
5033
+ srtpCipher: 'AEAD_AES_256_GCM',
5034
+ },
5035
+ ],
5036
+ [
5037
+ 'outbound-rtp-1',
5038
+ {
5039
+ type: 'outbound-rtp',
5040
+ ssrc: 12345,
5041
+ },
5042
+ ],
5043
+ ]);
5044
+
5045
+ statsAnalyzerStub.emit(
5046
+ {file: 'test', function: 'test'},
5047
+ StatsAnalyzerEventNames.STATS_UPDATE,
5048
+ {stats: fakeStats}
5049
+ );
5050
+
5051
+ assert.calledWith(
5052
+ TriggerProxy.trigger,
5053
+ sinon.match.instanceOf(Meeting),
5054
+ {
5055
+ file: 'meeting/index',
5056
+ function: 'setupStatsAnalyzerEventHandlers',
5057
+ },
5058
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5059
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5060
+ );
5061
+
5062
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5063
+ });
5064
+
5065
+ it('handles empty stats map without errors', async () => {
5066
+ const emptyStats = new Map();
5067
+
5068
+ statsAnalyzerStub.emit(
5069
+ {file: 'test', function: 'test'},
5070
+ StatsAnalyzerEventNames.STATS_UPDATE,
5071
+ {stats: emptyStats}
5072
+ );
5073
+
5074
+ assert.neverCalledWith(
5075
+ TriggerProxy.trigger,
5076
+ sinon.match.instanceOf(Meeting),
5077
+ sinon.match.any,
5078
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5079
+ sinon.match.any
5080
+ );
5081
+
5082
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5083
+ });
5084
+
5085
+ it('logs cipher change when cipher is updated', async () => {
5086
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
5087
+
5088
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
5089
+
5090
+ const newStats = new Map([
5091
+ [
5092
+ 'transport-1',
5093
+ {
5094
+ type: 'transport',
5095
+ srtpCipher: 'AEAD_AES_256_GCM',
5096
+ },
5097
+ ],
5098
+ ]);
5099
+
5100
+ statsAnalyzerStub.emit(
5101
+ {file: 'test', function: 'test'},
5102
+ StatsAnalyzerEventNames.STATS_UPDATE,
5103
+ {stats: newStats}
5104
+ );
5105
+
5106
+ assert.calledWithMatch(
5107
+ loggerSpy,
5108
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
5109
+ );
5110
+
5111
+ loggerSpy.restore();
5112
+ });
5113
+ });
4537
5114
  });
4538
5115
 
4539
5116
  describe('handles StatsMonitor events', () => {
@@ -6429,6 +7006,9 @@ describe('plugin-meetings', () => {
6429
7006
 
6430
7007
  meeting.annotation.deregisterEvents = sinon.stub();
6431
7008
  webex.internal.llm.off = sinon.stub();
7009
+ webex.internal.mercury.off = sinon.stub();
7010
+ meeting.mercuryOnlineHandler = sinon.stub();
7011
+ meeting.mercuryOfflineHandler = sinon.stub();
6432
7012
 
6433
7013
  // A meeting needs to be joined to leave
6434
7014
  meeting.meetingState = 'ACTIVE';
@@ -6452,6 +7032,67 @@ describe('plugin-meetings', () => {
6452
7032
  assert.calledOnce(meeting.clearMeetingData);
6453
7033
  });
6454
7034
 
7035
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
7036
+ const onlineHandler = meeting.mercuryOnlineHandler;
7037
+ const offlineHandler = meeting.mercuryOfflineHandler;
7038
+
7039
+ await meeting.leave();
7040
+
7041
+ // All llm/mercury consumers (direct listeners, voicea transcription,
7042
+ // annotation) must be detached before the /leave request so that
7043
+ // in-flight events do not trigger unnecessary Locus syncs
7044
+ // (per Locus team recommendation).
7045
+ assert.callOrder(
7046
+ webex.internal.llm.off,
7047
+ webex.internal.mercury.off,
7048
+ meeting.stopTranscription,
7049
+ meeting.annotation.deregisterEvents,
7050
+ meeting.meetingRequest.leaveMeeting
7051
+ );
7052
+ assert.calledWithExactly(
7053
+ webex.internal.llm.off,
7054
+ 'event:relay.event',
7055
+ meeting.processRelayEvent
7056
+ );
7057
+ assert.calledWithExactly(
7058
+ webex.internal.llm.off,
7059
+ LOCUS_LLM_EVENT,
7060
+ meeting.processLocusLLMEvent
7061
+ );
7062
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7063
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7064
+ assert.isUndefined(meeting.mercuryOnlineHandler);
7065
+ assert.isUndefined(meeting.mercuryOfflineHandler);
7066
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7067
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7068
+ assert.isUndefined(meeting.transcription);
7069
+ });
7070
+
7071
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
7072
+ const onlineHandler = meeting.mercuryOnlineHandler;
7073
+ const offlineHandler = meeting.mercuryOfflineHandler;
7074
+ meeting.meetingRequest.leaveMeeting = sinon
7075
+ .stub()
7076
+ .returns(Promise.reject(new Error('leave failed')));
7077
+
7078
+ await meeting.leave().catch(() => {});
7079
+
7080
+ assert.calledWithExactly(
7081
+ webex.internal.llm.off,
7082
+ 'event:relay.event',
7083
+ meeting.processRelayEvent
7084
+ );
7085
+ assert.calledWithExactly(
7086
+ webex.internal.llm.off,
7087
+ LOCUS_LLM_EVENT,
7088
+ meeting.processLocusLLMEvent
7089
+ );
7090
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7091
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7092
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7093
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7094
+ });
7095
+
6455
7096
  it('should reset call diagnostic latencies correctly', async () => {
6456
7097
  const leave = meeting.leave();
6457
7098
 
@@ -8459,6 +9100,9 @@ describe('plugin-meetings', () => {
8459
9100
 
8460
9101
  meeting.annotation.deregisterEvents = sinon.stub();
8461
9102
  webex.internal.llm.off = sinon.stub();
9103
+ webex.internal.mercury.off = sinon.stub();
9104
+ meeting.mercuryOnlineHandler = sinon.stub();
9105
+ meeting.mercuryOfflineHandler = sinon.stub();
8462
9106
 
8463
9107
  // A meeting needs to be joined to end
8464
9108
  meeting.meetingState = 'ACTIVE';
@@ -8481,6 +9125,66 @@ describe('plugin-meetings', () => {
8481
9125
  assert.calledOnce(meeting?.unsetPeerConnections);
8482
9126
  assert.calledOnce(meeting?.clearMeetingData);
8483
9127
  });
9128
+
9129
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
9130
+ const onlineHandler = meeting.mercuryOnlineHandler;
9131
+ const offlineHandler = meeting.mercuryOfflineHandler;
9132
+
9133
+ await meeting.endMeetingForAll();
9134
+
9135
+ // All llm/mercury consumers (direct listeners, voicea transcription,
9136
+ // annotation) must be detached before the /end request so that
9137
+ // in-flight events do not trigger unnecessary Locus syncs
9138
+ // (per Locus team recommendation).
9139
+ assert.callOrder(
9140
+ webex.internal.llm.off,
9141
+ webex.internal.mercury.off,
9142
+ meeting.stopTranscription,
9143
+ meeting.annotation.deregisterEvents,
9144
+ meeting.meetingRequest.endMeetingForAll
9145
+ );
9146
+ assert.calledWithExactly(
9147
+ webex.internal.llm.off,
9148
+ 'event:relay.event',
9149
+ meeting.processRelayEvent
9150
+ );
9151
+ assert.calledWithExactly(
9152
+ webex.internal.llm.off,
9153
+ LOCUS_LLM_EVENT,
9154
+ meeting.processLocusLLMEvent
9155
+ );
9156
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9157
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9158
+ assert.isUndefined(meeting.mercuryOnlineHandler);
9159
+ assert.isUndefined(meeting.mercuryOfflineHandler);
9160
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9161
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9162
+ });
9163
+
9164
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
9165
+ const onlineHandler = meeting.mercuryOnlineHandler;
9166
+ const offlineHandler = meeting.mercuryOfflineHandler;
9167
+ meeting.meetingRequest.endMeetingForAll = sinon
9168
+ .stub()
9169
+ .returns(Promise.reject(new Error('end failed')));
9170
+
9171
+ await meeting.endMeetingForAll().catch(() => {});
9172
+
9173
+ assert.calledWithExactly(
9174
+ webex.internal.llm.off,
9175
+ 'event:relay.event',
9176
+ meeting.processRelayEvent
9177
+ );
9178
+ assert.calledWithExactly(
9179
+ webex.internal.llm.off,
9180
+ LOCUS_LLM_EVENT,
9181
+ meeting.processLocusLLMEvent
9182
+ );
9183
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9184
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9185
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9186
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9187
+ });
8484
9188
  });
8485
9189
 
8486
9190
  describe('#moveTo', () => {
@@ -10417,7 +11121,7 @@ describe('plugin-meetings', () => {
10417
11121
  );
10418
11122
  done();
10419
11123
  });
10420
- it('listens to the self admitted guest event without blocking on token prefetch', async () => {
11124
+ it('listens to the self admitted guest event and waits for token prefetch before reconnecting LLM', async () => {
10421
11125
  meeting.stopKeepAlive = sinon.stub();
10422
11126
  meeting.updateLLMConnection = sinon.stub();
10423
11127
  let resolvePrefetch;
@@ -10443,7 +11147,7 @@ describe('plugin-meetings', () => {
10443
11147
  'meeting:self:guestAdmitted',
10444
11148
  {payload: test1}
10445
11149
  );
10446
- assert.calledOnce(meeting.updateLLMConnection);
11150
+ assert.notCalled(meeting.updateLLMConnection);
10447
11151
  assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
10448
11152
 
10449
11153
  assert.calledOnceWithExactly(
@@ -10456,6 +11160,7 @@ describe('plugin-meetings', () => {
10456
11160
 
10457
11161
  resolvePrefetch(false);
10458
11162
  await Promise.resolve();
11163
+ await Promise.resolve();
10459
11164
 
10460
11165
  assert.calledOnce(meeting.updateLLMConnection);
10461
11166
  });
@@ -10971,6 +11676,92 @@ describe('plugin-meetings', () => {
10971
11676
  );
10972
11677
  });
10973
11678
 
11679
+ const recordingTestCases = [
11680
+ {
11681
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
11682
+ state: RECORDING_STATE.RECORDING,
11683
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11684
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11685
+ },
11686
+ {
11687
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
11688
+ state: RECORDING_STATE.IDLE,
11689
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
11690
+ expectedRecordingState: RECORDING_STATE.IDLE,
11691
+ },
11692
+ {
11693
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
11694
+ state: RECORDING_STATE.PAUSED,
11695
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
11696
+ expectedRecordingState: RECORDING_STATE.PAUSED,
11697
+ },
11698
+ {
11699
+ description:
11700
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
11701
+ state: RECORDING_STATE.RESUMED,
11702
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
11703
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11704
+ },
11705
+ ];
11706
+
11707
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
11708
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
11709
+ const modifiedBy = 'user-id-123';
11710
+ const lastModified = '2026-01-01T00:00:00Z';
11711
+
11712
+ await meeting.locusInfo.emitScoped(
11713
+ {function: 'test', file: 'test'},
11714
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11715
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
11716
+ );
11717
+
11718
+ assert.deepEqual(meeting.recording, {
11719
+ state: expectedRecordingState,
11720
+ modifiedBy,
11721
+ lastModified,
11722
+ modifiedByServiceAppName: undefined,
11723
+ modifiedByServiceAppId: undefined,
11724
+ });
11725
+
11726
+ assert.calledWith(
11727
+ TriggerProxy.trigger,
11728
+ meeting,
11729
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11730
+ expectedEvent,
11731
+ meeting.recording
11732
+ );
11733
+ });
11734
+ });
11735
+
11736
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
11737
+ const modifiedBy = 'user-id-123';
11738
+ const lastModified = '2026-01-01T00:00:00Z';
11739
+ const modifiedByServiceAppName = 'My Bot';
11740
+ const modifiedByServiceAppId = 'app-id-123';
11741
+
11742
+ await meeting.locusInfo.emitScoped(
11743
+ {function: 'test', file: 'test'},
11744
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11745
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
11746
+ );
11747
+
11748
+ assert.deepEqual(meeting.recording, {
11749
+ state: RECORDING_STATE.RECORDING,
11750
+ modifiedBy,
11751
+ lastModified,
11752
+ modifiedByServiceAppName,
11753
+ modifiedByServiceAppId,
11754
+ });
11755
+
11756
+ assert.calledWith(
11757
+ TriggerProxy.trigger,
11758
+ meeting,
11759
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11760
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11761
+ meeting.recording
11762
+ );
11763
+ });
11764
+
10974
11765
  it('listens to the locus interpretation update event', () => {
10975
11766
  const interpretation = {
10976
11767
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11024,6 +11815,7 @@ describe('plugin-meetings', () => {
11024
11815
  meeting.annotation.locusUrlUpdate = sinon.stub();
11025
11816
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11026
11817
  meeting.webinar.locusUrlUpdate = sinon.stub();
11818
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11027
11819
 
11028
11820
  meeting.locusInfo.emit(
11029
11821
  {function: 'test', file: 'test'},
@@ -11038,6 +11830,7 @@ describe('plugin-meetings', () => {
11038
11830
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11039
11831
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11040
11832
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
11833
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11041
11834
  assert.equal(meeting.locusUrl, newLocusUrl);
11042
11835
  assert(meeting.locusId, '12345');
11043
11836
 
@@ -11353,6 +12146,109 @@ describe('plugin-meetings', () => {
11353
12146
  });
11354
12147
  });
11355
12148
 
12149
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
12150
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
12151
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12152
+
12153
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
12154
+ meeting.destination = {info: {topic: 'old'}};
12155
+
12156
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12157
+
12158
+ assert.equal(meeting.destination, syncedLocus);
12159
+ });
12160
+
12161
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
12162
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12163
+ const originalDestination = {destination: 'original-destination'};
12164
+
12165
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
12166
+ meeting.destination = originalDestination;
12167
+
12168
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12169
+
12170
+ assert.equal(meeting.destination, originalDestination);
12171
+ });
12172
+
12173
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
12174
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12175
+
12176
+ meeting.meetingInfo = {};
12177
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12178
+
12179
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12180
+
12181
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
12182
+ });
12183
+
12184
+ it('does not fetch meeting info when destination has no info', () => {
12185
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12186
+
12187
+ meeting.meetingInfo = {};
12188
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
12189
+
12190
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12191
+
12192
+ assert.notCalled(fetchMeetingInfoStub);
12193
+ });
12194
+
12195
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
12196
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12197
+
12198
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
12199
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12200
+
12201
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12202
+
12203
+ assert.notCalled(fetchMeetingInfoStub);
12204
+ });
12205
+
12206
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
12207
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12208
+
12209
+ meeting.meetingInfo = {};
12210
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12211
+ meeting.fetchMeetingInfoTimeoutId = 42;
12212
+
12213
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12214
+
12215
+ assert.notCalled(fetchMeetingInfoStub);
12216
+ });
12217
+
12218
+ ['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
12219
+ it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
12220
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12221
+
12222
+ meeting.meetingInfo = {};
12223
+ meeting.destination = {
12224
+ url: 'https://locus.example.com/locus/123',
12225
+ info: {topic: 'x'},
12226
+ };
12227
+
12228
+ meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
12229
+
12230
+ assert.notCalled(fetchMeetingInfoStub);
12231
+ });
12232
+ });
12233
+
12234
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
12235
+ const error = new Error('fetch failed');
12236
+
12237
+ meeting.meetingInfo = {};
12238
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12239
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
12240
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
12241
+
12242
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
12243
+
12244
+ assert.calledOnce(loggerInfoStub);
12245
+ assert.match(
12246
+ loggerInfoStub.firstCall.args[0],
12247
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
12248
+ );
12249
+ });
12250
+ });
12251
+
11356
12252
  describe('#emailInput', () => {
11357
12253
  it('should set the email input', () => {
11358
12254
  assert.notOk(meeting.emailInput);
@@ -11955,6 +12851,7 @@ describe('plugin-meetings', () => {
11955
12851
  let showAutoEndMeetingWarningSpy;
11956
12852
  let canAttendeeRequestAiAssistantEnabledSpy;
11957
12853
  let attendeeRequestAiAssistantDeclinedAllSpy;
12854
+ let isAnonymizeDisplayNamesEnabledSpy;
11958
12855
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11959
12856
 
11960
12857
  beforeEach(() => {
@@ -12003,6 +12900,10 @@ describe('plugin-meetings', () => {
12003
12900
  MeetingUtil,
12004
12901
  'attendeeRequestAiAssistantDeclinedAll'
12005
12902
  );
12903
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
12904
+ MeetingUtil,
12905
+ 'isAnonymizeDisplayNamesEnabled'
12906
+ );
12006
12907
  });
12007
12908
 
12008
12909
  afterEach(() => {
@@ -12011,6 +12912,7 @@ describe('plugin-meetings', () => {
12011
12912
  showAutoEndMeetingWarningSpy.restore();
12012
12913
  canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
12914
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
12915
+ isAnonymizeDisplayNamesEnabledSpy.restore();
12014
12916
  });
12015
12917
 
12016
12918
  forEach(
@@ -12568,6 +13470,7 @@ describe('plugin-meetings', () => {
12568
13470
  meeting.roles
12569
13471
  );
12570
13472
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13473
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12571
13474
 
12572
13475
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12573
13476
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12782,6 +13685,10 @@ describe('plugin-meetings', () => {
12782
13685
  describe('#saveDataChannelToken', () => {
12783
13686
  beforeEach(() => {
12784
13687
  webex.internal.llm.setDatachannelToken = sinon.stub();
13688
+ webex.internal.llm.resolveSessionOwnership = sinon
13689
+ .stub()
13690
+ .returns({currentOwner: undefined, isOwner: true});
13691
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
12785
13692
  });
12786
13693
 
12787
13694
  it('saves datachannelToken into LLM as Default', () => {
@@ -12794,7 +13701,8 @@ describe('plugin-meetings', () => {
12794
13701
  assert.calledWithExactly(
12795
13702
  webex.internal.llm.setDatachannelToken,
12796
13703
  'default-token',
12797
- 'llm-default-session'
13704
+ 'llm-default-session',
13705
+ meeting.id
12798
13706
  );
12799
13707
  });
12800
13708
 
@@ -12808,7 +13716,8 @@ describe('plugin-meetings', () => {
12808
13716
  assert.calledWithExactly(
12809
13717
  webex.internal.llm.setDatachannelToken,
12810
13718
  'ps-token',
12811
- 'llm-practice-session'
13719
+ 'llm-practice-session',
13720
+ meeting.id
12812
13721
  );
12813
13722
  });
12814
13723
 
@@ -12826,12 +13735,14 @@ describe('plugin-meetings', () => {
12826
13735
  assert.calledWithExactly(
12827
13736
  webex.internal.llm.setDatachannelToken,
12828
13737
  'default-token',
12829
- 'llm-default-session'
13738
+ 'llm-default-session',
13739
+ meeting.id
12830
13740
  );
12831
13741
  assert.calledWithExactly(
12832
13742
  webex.internal.llm.setDatachannelToken,
12833
13743
  'ps-token',
12834
- 'llm-practice-session'
13744
+ 'llm-practice-session',
13745
+ meeting.id
12835
13746
  );
12836
13747
  });
12837
13748
 
@@ -12852,17 +13763,42 @@ describe('plugin-meetings', () => {
12852
13763
 
12853
13764
  assert.notCalled(webex.internal.llm.setDatachannelToken);
12854
13765
  });
13766
+
13767
+ it('writes token with meeting id as owner', () => {
13768
+ meeting.saveDataChannelToken({
13769
+ locus: {
13770
+ self: {datachannelToken: 'default-token'},
13771
+ },
13772
+ });
13773
+
13774
+ assert.calledOnceWithExactly(
13775
+ webex.internal.llm.setDatachannelToken,
13776
+ 'default-token',
13777
+ 'llm-default-session',
13778
+ meeting.id
13779
+ );
13780
+ });
12855
13781
  });
12856
13782
 
12857
13783
  describe('#clearDataChannelToken', () => {
12858
13784
  beforeEach(() => {
12859
- webex.internal.llm.resetDatachannelTokens = sinon.stub();
13785
+ webex.internal.llm.clearDatachannelToken = sinon.stub();
12860
13786
  });
12861
13787
 
12862
- it('calls resetDatachannelTokens on LLM', () => {
13788
+ it('delegates default and practice token clears to llm with meeting ownership id', () => {
12863
13789
  meeting.clearDataChannelToken();
12864
13790
 
12865
- assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
13791
+ assert.calledWithExactly(
13792
+ webex.internal.llm.clearDatachannelToken,
13793
+ 'llm-default-session',
13794
+ meeting.id
13795
+ );
13796
+ assert.calledWithExactly(
13797
+ webex.internal.llm.clearDatachannelToken,
13798
+ 'llm-practice-session',
13799
+ meeting.id
13800
+ );
13801
+ assert.callCount(webex.internal.llm.clearDatachannelToken, 2);
12866
13802
  });
12867
13803
  });
12868
13804
 
@@ -12872,6 +13808,7 @@ describe('plugin-meetings', () => {
12872
13808
  webex.internal.llm.getLocusUrl = sinon.stub();
12873
13809
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12874
13810
  webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
13811
+ webex.internal.llm.setRefreshHandler = sinon.stub();
12875
13812
  webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12876
13813
  webex.internal.llm.on = sinon.stub();
12877
13814
  webex.internal.llm.off = sinon.stub();
@@ -12889,7 +13826,7 @@ describe('plugin-meetings', () => {
12889
13826
  meeting.joinedWith = {state: 'any other state'};
12890
13827
  webex.internal.llm.getLocusUrl.returns('a url');
12891
13828
 
12892
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13829
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12893
13830
 
12894
13831
  const result = await meeting.updateLLMConnection();
12895
13832
 
@@ -12901,6 +13838,7 @@ describe('plugin-meetings', () => {
12901
13838
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12902
13839
  meeting.joinedWith = {state: 'JOINED'};
12903
13840
  meeting.locusInfo = {
13841
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12904
13842
  url: 'a url',
12905
13843
  info: {datachannelUrl: 'a datachannel url'},
12906
13844
  };
@@ -12937,7 +13875,7 @@ describe('plugin-meetings', () => {
12937
13875
  });
12938
13876
  it('connects if not already connected', async () => {
12939
13877
  meeting.joinedWith = {state: 'JOINED'};
12940
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13878
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12941
13879
 
12942
13880
  const result = await meeting.updateLLMConnection();
12943
13881
 
@@ -12948,7 +13886,14 @@ describe('plugin-meetings', () => {
12948
13886
  'a datachannel url',
12949
13887
  undefined
12950
13888
  );
13889
+ assert.calledOnceWithExactly(
13890
+ webex.internal.llm.setRefreshHandler,
13891
+ sinon.match.func,
13892
+ 'llm-default-session',
13893
+ meeting.id
13894
+ );
12951
13895
  assert.equal(result, 'something');
13896
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
12952
13897
  });
12953
13898
  it('disconnects if the locus url has changed', async () => {
12954
13899
  meeting.joinedWith = {state: 'JOINED'};
@@ -12957,6 +13902,7 @@ describe('plugin-meetings', () => {
12957
13902
  webex.internal.llm.getLocusUrl.returns('a url');
12958
13903
 
12959
13904
  meeting.locusInfo = {
13905
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12960
13906
  url: 'a different url',
12961
13907
  info: {datachannelUrl: 'a datachannel url'},
12962
13908
  self: {},
@@ -12967,7 +13913,7 @@ describe('plugin-meetings', () => {
12967
13913
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12968
13914
  code: 3050,
12969
13915
  reason: 'done (permanent)',
12970
- });
13916
+ }, 'llm-default-session', meeting.id);
12971
13917
 
12972
13918
  assert.calledWithExactly(
12973
13919
  webex.internal.llm.registerAndConnect,
@@ -13010,6 +13956,7 @@ describe('plugin-meetings', () => {
13010
13956
  webex.internal.llm.getLocusUrl.returns('a url');
13011
13957
 
13012
13958
  meeting.locusInfo = {
13959
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13013
13960
  url: 'a url',
13014
13961
  info: {datachannelUrl: 'a different datachannel url'},
13015
13962
  self: {},
@@ -13020,7 +13967,7 @@ describe('plugin-meetings', () => {
13020
13967
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
13021
13968
  code: 3050,
13022
13969
  reason: 'done (permanent)',
13023
- });
13970
+ }, 'llm-default-session', meeting.id);
13024
13971
 
13025
13972
  assert.calledWithExactly(
13026
13973
  webex.internal.llm.registerAndConnect,
@@ -13061,14 +14008,14 @@ describe('plugin-meetings', () => {
13061
14008
  webex.internal.llm.isConnected.returns(true);
13062
14009
  webex.internal.llm.getLocusUrl.returns('a url');
13063
14010
 
13064
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14011
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13065
14012
 
13066
14013
  const result = await meeting.updateLLMConnection();
13067
14014
 
13068
14015
  assert.calledWith(webex.internal.llm.disconnectLLM, {
13069
14016
  code: 3050,
13070
14017
  reason: 'done (permanent)',
13071
- });
14018
+ }, 'llm-default-session', meeting.id);
13072
14019
  assert.notCalled(webex.internal.llm.registerAndConnect);
13073
14020
  assert.equal(result, undefined);
13074
14021
  assert.isFalse(
@@ -13084,6 +14031,7 @@ describe('plugin-meetings', () => {
13084
14031
  webex.internal.llm.disconnectLLM.rejects(disconnectError);
13085
14032
 
13086
14033
  meeting.locusInfo = {
14034
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13087
14035
  url: 'a different url',
13088
14036
  info: {datachannelUrl: 'a datachannel url'},
13089
14037
  self: {},
@@ -13115,6 +14063,7 @@ describe('plugin-meetings', () => {
13115
14063
  it('still need connect main session data channel when PS started', async () => {
13116
14064
  meeting.joinedWith = {state: 'JOINED'};
13117
14065
  meeting.locusInfo = {
14066
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13118
14067
  url: 'a url',
13119
14068
  info: {
13120
14069
  datachannelUrl: 'a datachannel url',
@@ -13135,11 +14084,14 @@ describe('plugin-meetings', () => {
13135
14084
  it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13136
14085
  meeting.joinedWith = {state: 'JOINED'};
13137
14086
  meeting.locusInfo = {
14087
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13138
14088
  url: 'a url',
13139
14089
  info: {datachannelUrl: 'a datachannel url'},
13140
14090
  };
13141
14091
 
13142
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
14092
+ webex.internal.llm.getDatachannelToken
14093
+ .withArgs('llm-default-session', meeting.id)
14094
+ .returns('token-123');
13143
14095
 
13144
14096
  await meeting.updateLLMConnection();
13145
14097
 
@@ -13154,6 +14106,7 @@ describe('plugin-meetings', () => {
13154
14106
  it('passes undefined token when LLM has no token stored', async () => {
13155
14107
  meeting.joinedWith = {state: 'JOINED'};
13156
14108
  meeting.locusInfo = {
14109
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13157
14110
  url: 'a url',
13158
14111
  info: {datachannelUrl: 'a datachannel url'},
13159
14112
  };
@@ -13175,6 +14128,7 @@ describe('plugin-meetings', () => {
13175
14128
  it('does not pass token when data channel with jwt token is disabled', async () => {
13176
14129
  meeting.joinedWith = {state: 'JOINED'};
13177
14130
  meeting.locusInfo = {
14131
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13178
14132
  url: 'a url',
13179
14133
  info: {datachannelUrl: 'a datachannel url'},
13180
14134
  };
@@ -13193,6 +14147,197 @@ describe('plugin-meetings', () => {
13193
14147
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
14148
  });
13195
14149
 
14150
+ describe('ownership tag', () => {
14151
+ beforeEach(() => {
14152
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
14153
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
14154
+ // real LLM singleton behavior so the finally-block release in
14155
+ // cleanupLLMConneciton is reflected in subsequent reads.
14156
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
14157
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
14158
+ webex.internal.llm.getOwnerMeetingId.returns(id);
14159
+ });
14160
+ });
14161
+
14162
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
14163
+ meeting.joinedWith = {state: 'JOINED'};
14164
+ webex.internal.llm.isConnected.returns(true);
14165
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14166
+ // Locus/datachannel URL mismatch is the *normal* case when
14167
+ // another meeting owns the live socket -- each meeting has its
14168
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
14169
+ // because doing so would tear down the owning meeting's healthy
14170
+ // LLM socket and break its data channel.
14171
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
14172
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
14173
+ meeting.locusInfo = {
14174
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14175
+ url: 'a different url',
14176
+ info: {datachannelUrl: 'a different datachannel url'},
14177
+ self: {},
14178
+ };
14179
+
14180
+ const result = await meeting.updateLLMConnection();
14181
+
14182
+ assert.equal(result, undefined);
14183
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14184
+ assert.notCalled(webex.internal.llm.registerAndConnect);
14185
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14186
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
14187
+ });
14188
+
14189
+
14190
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
14191
+ meeting.joinedWith = {state: 'JOINED'};
14192
+ webex.internal.llm.isConnected.returns(true);
14193
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14194
+ webex.internal.llm.getLocusUrl.returns('a url');
14195
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14196
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
14197
+ meeting.locusInfo = {
14198
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14199
+ url: 'a different url',
14200
+ info: {datachannelUrl: 'a datachannel url'},
14201
+ self: {},
14202
+ };
14203
+
14204
+ try {
14205
+ await meeting.updateLLMConnection();
14206
+ } catch (e) {
14207
+ /* updateLLMConnection may reject when cleanup throws */
14208
+ }
14209
+
14210
+ // The owner-eligible finally branch must release the tag so a
14211
+ // subsequent reconnect attempt from any meeting is not blocked.
14212
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
14213
+ });
14214
+
14215
+ it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => {
14216
+ meeting.joinedWith = {state: 'JOINED'};
14217
+ webex.internal.llm.isConnected.returns(true);
14218
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14219
+ webex.internal.llm.getLocusUrl.returns('a url');
14220
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14221
+ webex.internal.llm.disconnectLLM.callsFake(async () => {
14222
+ webex.internal.llm.getOwnerMeetingId.returns('new-owner-id');
14223
+ throw new Error('disconnect failed');
14224
+ });
14225
+ meeting.locusInfo = {
14226
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14227
+ url: 'a different url',
14228
+ info: {datachannelUrl: 'a datachannel url'},
14229
+ self: {},
14230
+ };
14231
+
14232
+ try {
14233
+ await meeting.updateLLMConnection();
14234
+ } catch (e) {
14235
+ /* updateLLMConnection may reject when cleanup throws */
14236
+ }
14237
+
14238
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14239
+ assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id');
14240
+ });
14241
+
14242
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
14243
+ meeting.joinedWith = {state: 'JOINED'};
14244
+ webex.internal.llm.isConnected.returns(true);
14245
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14246
+ webex.internal.llm.getLocusUrl.returns('a url');
14247
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14248
+ meeting.locusInfo = {
14249
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14250
+ url: 'a different url',
14251
+ info: {datachannelUrl: 'a datachannel url'},
14252
+ self: {},
14253
+ };
14254
+
14255
+ await meeting.updateLLMConnection();
14256
+
14257
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14258
+ code: 3050,
14259
+ reason: 'done (permanent)',
14260
+ }, 'llm-default-session', meeting.id);
14261
+ assert.calledWithExactly(
14262
+ webex.internal.llm.registerAndConnect,
14263
+ 'a different url',
14264
+ 'a datachannel url',
14265
+ undefined
14266
+ );
14267
+ // setOwnerMeetingId is called twice: first with undefined in
14268
+ // cleanupLLMConneciton's finally block (so a failed disconnect
14269
+ // cannot leave a stale owner), then with this meeting's id
14270
+ // after registerAndConnect resolves.
14271
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
14272
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
14273
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
14274
+ });
14275
+
14276
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
14277
+ meeting.joinedWith = {state: 'JOINED'};
14278
+ webex.internal.llm.isConnected.returns(false);
14279
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14280
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14281
+
14282
+ await meeting.updateLLMConnection();
14283
+
14284
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14285
+ assert.calledOnceWithExactly(
14286
+ webex.internal.llm.setRefreshHandler,
14287
+ sinon.match.func,
14288
+ 'llm-default-session',
14289
+ meeting.id
14290
+ );
14291
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14292
+ });
14293
+
14294
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
14295
+ // Defensive path: if the LLM reports not-connected but an old
14296
+ // ownerId is still present (e.g. race before a successful
14297
+ // connections.delete), this meeting can still claim a fresh
14298
+ // connection.
14299
+ meeting.joinedWith = {state: 'JOINED'};
14300
+ webex.internal.llm.isConnected.returns(false);
14301
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
14302
+ webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined);
14303
+ webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token');
14304
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14305
+
14306
+ await meeting.updateLLMConnection();
14307
+
14308
+ assert.calledTwice(webex.internal.llm.getDatachannelToken);
14309
+ assert.calledWithExactly(
14310
+ webex.internal.llm.getDatachannelToken.firstCall,
14311
+ 'llm-default-session',
14312
+ meeting.id
14313
+ );
14314
+ assert.calledWithExactly(
14315
+ webex.internal.llm.getDatachannelToken.secondCall,
14316
+ 'llm-default-session'
14317
+ );
14318
+ assert.calledOnceWithExactly(
14319
+ webex.internal.llm.registerAndConnect,
14320
+ 'a url',
14321
+ 'a datachannel url',
14322
+ 'recovered-token'
14323
+ );
14324
+ assert.calledWithExactly(
14325
+ webex.internal.llm.setRefreshHandler.firstCall,
14326
+ sinon.match.func,
14327
+ 'llm-default-session',
14328
+ undefined
14329
+ );
14330
+ assert.calledTwice(webex.internal.llm.setRefreshHandler);
14331
+ assert.calledWithExactly(
14332
+ webex.internal.llm.setRefreshHandler.secondCall,
14333
+ sinon.match.func,
14334
+ 'llm-default-session',
14335
+ meeting.id
14336
+ );
14337
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14338
+ });
14339
+ });
14340
+
13196
14341
  describe('#clearMeetingData', () => {
13197
14342
  beforeEach(() => {
13198
14343
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13211,7 +14356,7 @@ describe('plugin-meetings', () => {
13211
14356
  assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13212
14357
  code: 3050,
13213
14358
  reason: 'done (permanent)',
13214
- });
14359
+ }, 'llm-default-session', meeting.id);
13215
14360
  assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13216
14361
  assert.calledWithExactly(
13217
14362
  webex.internal.llm.off,
@@ -13224,10 +14369,13 @@ describe('plugin-meetings', () => {
13224
14369
  meeting.processLocusLLMEvent
13225
14370
  );
13226
14371
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
- assert.calledOnce(meeting.stopTranscription);
13228
- assert.isUndefined(meeting.transcription);
13229
14372
  assert.calledOnce(meeting.clearDataChannelToken);
13230
- assert.calledOnce(meeting.annotation.deregisterEvents);
14373
+ // stopTranscription and annotation.deregisterEvents are not
14374
+ // called here: they run in stopListeningForMeetingEvents()
14375
+ // before /leave to avoid double-emitting
14376
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
14377
+ assert.notCalled(meeting.stopTranscription);
14378
+ assert.notCalled(meeting.annotation.deregisterEvents);
13231
14379
  });
13232
14380
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
14381
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13246,19 +14394,65 @@ describe('plugin-meetings', () => {
13246
14394
  meeting.processLocusLLMEvent
13247
14395
  );
13248
14396
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
- assert.calledOnce(meeting.stopTranscription);
13250
- assert.isUndefined(meeting.transcription);
13251
14397
  assert.calledOnce(meeting.clearDataChannelToken);
13252
- assert.calledOnce(meeting.annotation.deregisterEvents);
14398
+ assert.notCalled(meeting.stopTranscription);
14399
+ assert.notCalled(meeting.annotation.deregisterEvents);
13253
14400
  });
13254
- it('always calls stopTranscription even when transcription is undefined', async () => {
13255
- meeting.transcription = undefined;
13256
14401
 
13257
- await meeting.clearMeetingData();
14402
+ describe('ownership tag', () => {
14403
+ beforeEach(() => {
14404
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
14405
+ });
13258
14406
 
13259
- assert.calledOnce(meeting.stopTranscription);
13260
- assert.isUndefined(meeting.transcription);
13261
- assert.calledOnce(meeting.clearDataChannelToken);
14407
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
14408
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14409
+
14410
+ await meeting.clearMeetingData();
14411
+
14412
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14413
+ // clearDataChannelToken is always delegated; llm enforces
14414
+ // ownership and no-ops for non-owners internally.
14415
+ assert.calledOnce(meeting.clearDataChannelToken);
14416
+ // Listeners owned by *this* Meeting instance must still be
14417
+ // removed so a leaving subordinate meeting stops receiving
14418
+ // relay/locus events from the shared singleton.
14419
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14420
+ assert.calledWithExactly(
14421
+ webex.internal.llm.off,
14422
+ 'event:relay.event',
14423
+ meeting.processRelayEvent
14424
+ );
14425
+ assert.calledWithExactly(
14426
+ webex.internal.llm.off,
14427
+ 'event:locus.state_message',
14428
+ meeting.processLocusLLMEvent
14429
+ );
14430
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14431
+ });
14432
+
14433
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14434
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14435
+
14436
+ await meeting.clearMeetingData();
14437
+
14438
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14439
+ code: 3050,
14440
+ reason: 'done (permanent)',
14441
+ }, 'llm-default-session', meeting.id);
14442
+ assert.calledOnce(meeting.clearDataChannelToken);
14443
+ });
14444
+
14445
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14446
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14447
+
14448
+ await meeting.clearMeetingData();
14449
+
14450
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14451
+ code: 3050,
14452
+ reason: 'done (permanent)',
14453
+ }, 'llm-default-session', meeting.id);
14454
+ assert.calledOnce(meeting.clearDataChannelToken);
14455
+ });
13262
14456
  });
13263
14457
  });
13264
14458
  });
@@ -15012,16 +16206,25 @@ describe('plugin-meetings', () => {
15012
16206
  assert.notCalled(meeting.meetingRequest.sendReaction);
15013
16207
  });
15014
16208
 
15015
- it('should fail sending a reaction if reactionType is invalid ', async () => {
16209
+ it('should send a custom reaction type not in the known list', async () => {
15016
16210
  meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
15017
16211
 
15018
- await assert.isRejected(
15019
- meeting.sendReaction('invalid_reaction', 'light'),
15020
- Error,
15021
- 'invalid_reaction is not a valid reaction.'
15022
- );
16212
+ const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
15023
16213
 
15024
- assert.notCalled(meeting.meetingRequest.sendReaction);
16214
+ assert.exists(reactionPromise.then);
16215
+ await reactionPromise;
16216
+ assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
16217
+ reactionChannelUrl: 'Fake URL',
16218
+ reaction: {
16219
+ type: 'custom_reaction',
16220
+ tone: {
16221
+ type: 'light_skin_tone',
16222
+ codepoints: '1F3FB',
16223
+ shortcodes: ':skin-tone-2:',
16224
+ },
16225
+ },
16226
+ participantId: meeting.members.selfId,
16227
+ });
15025
16228
  });
15026
16229
 
15027
16230
  it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
@@ -16045,4 +17248,4 @@ describe('plugin-meetings', () => {
16045
17248
  assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
16046
17249
  });
16047
17250
  });
16048
- });
17251
+ });