@webex/plugin-meetings 3.12.0-next.9 → 3.12.0-next.90

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 (213) 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 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +35 -9
  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 +67 -29
  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 +303 -88
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/infoUtils.js +36 -5
  40. package/dist/locus-info/infoUtils.js.map +1 -1
  41. package/dist/locus-info/types.js +19 -0
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/index.js +3 -1
  44. package/dist/media/index.js.map +1 -1
  45. package/dist/media/properties.js +1 -0
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +5 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1233 -793
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/locusMediaRequest.js +229 -82
  52. package/dist/meeting/locusMediaRequest.js.map +1 -1
  53. package/dist/meeting/muteState.js +10 -1
  54. package/dist/meeting/muteState.js.map +1 -1
  55. package/dist/meeting/request.js +5 -2
  56. package/dist/meeting/request.js.map +1 -1
  57. package/dist/meeting/util.js +35 -2
  58. package/dist/meeting/util.js.map +1 -1
  59. package/dist/meeting-info/meeting-info-v2.js +2 -2
  60. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  61. package/dist/meetings/index.js +231 -118
  62. package/dist/meetings/index.js.map +1 -1
  63. package/dist/meetings/meetings.types.js +6 -1
  64. package/dist/meetings/meetings.types.js.map +1 -1
  65. package/dist/meetings/request.js +39 -0
  66. package/dist/meetings/request.js.map +1 -1
  67. package/dist/meetings/util.js +79 -5
  68. package/dist/meetings/util.js.map +1 -1
  69. package/dist/member/index.js +10 -0
  70. package/dist/member/index.js.map +1 -1
  71. package/dist/member/types.js.map +1 -1
  72. package/dist/member/util.js +30 -0
  73. package/dist/member/util.js.map +1 -1
  74. package/dist/members/index.js +101 -43
  75. package/dist/members/index.js.map +1 -1
  76. package/dist/members/util.js +8 -1
  77. package/dist/members/util.js.map +1 -1
  78. package/dist/metrics/constants.js +5 -1
  79. package/dist/metrics/constants.js.map +1 -1
  80. package/dist/multistream/codec/constants.js +63 -0
  81. package/dist/multistream/codec/constants.js.map +1 -0
  82. package/dist/multistream/mediaRequestManager.js +62 -15
  83. package/dist/multistream/mediaRequestManager.js.map +1 -1
  84. package/dist/multistream/receiveSlot.js +9 -0
  85. package/dist/multistream/receiveSlot.js.map +1 -1
  86. package/dist/reachability/index.js +10 -13
  87. package/dist/reachability/index.js.map +1 -1
  88. package/dist/reachability/reachability.types.js +2 -0
  89. package/dist/reachability/reachability.types.js.map +1 -1
  90. package/dist/reachability/util.js +45 -0
  91. package/dist/reachability/util.js.map +1 -1
  92. package/dist/reactions/reactions.type.js.map +1 -1
  93. package/dist/recording-controller/index.js +1 -3
  94. package/dist/recording-controller/index.js.map +1 -1
  95. package/dist/types/config.d.ts +2 -2
  96. package/dist/types/constants.d.ts +12 -1
  97. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  98. package/dist/types/controls-options-manager/index.d.ts +16 -0
  99. package/dist/types/hashTree/constants.d.ts +2 -0
  100. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  101. package/dist/types/hashTree/utils.d.ts +18 -0
  102. package/dist/types/index.d.ts +3 -0
  103. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  104. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  105. package/dist/types/locus-info/index.d.ts +50 -6
  106. package/dist/types/locus-info/infoUtils.d.ts +8 -0
  107. package/dist/types/locus-info/types.d.ts +21 -1
  108. package/dist/types/media/properties.d.ts +1 -0
  109. package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
  110. package/dist/types/meeting/index.d.ts +94 -7
  111. package/dist/types/meeting/locusMediaRequest.d.ts +12 -0
  112. package/dist/types/meeting/request.d.ts +1 -0
  113. package/dist/types/meeting/util.d.ts +9 -0
  114. package/dist/types/meetings/index.d.ts +30 -18
  115. package/dist/types/meetings/meetings.types.d.ts +15 -0
  116. package/dist/types/meetings/request.d.ts +14 -0
  117. package/dist/types/member/index.d.ts +1 -0
  118. package/dist/types/member/types.d.ts +1 -0
  119. package/dist/types/member/util.d.ts +9 -0
  120. package/dist/types/members/index.d.ts +15 -2
  121. package/dist/types/members/util.d.ts +1 -5
  122. package/dist/types/metrics/constants.d.ts +4 -0
  123. package/dist/types/multistream/codec/constants.d.ts +7 -0
  124. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  125. package/dist/types/reachability/index.d.ts +1 -1
  126. package/dist/types/reachability/reachability.types.d.ts +7 -0
  127. package/dist/types/reachability/util.d.ts +17 -0
  128. package/dist/types/reactions/reactions.type.d.ts +3 -0
  129. package/dist/webinar/index.js +305 -159
  130. package/dist/webinar/index.js.map +1 -1
  131. package/package.json +22 -22
  132. package/src/aiEnableRequest/index.ts +16 -0
  133. package/src/breakouts/breakout.ts +3 -1
  134. package/src/breakouts/index.ts +31 -0
  135. package/src/config.ts +2 -2
  136. package/src/constants.ts +20 -3
  137. package/src/controls-options-manager/constants.ts +14 -1
  138. package/src/controls-options-manager/index.ts +87 -28
  139. package/src/controls-options-manager/util.ts +81 -1
  140. package/src/hashTree/constants.ts +16 -0
  141. package/src/hashTree/hashTreeParser.ts +580 -196
  142. package/src/hashTree/utils.ts +36 -0
  143. package/src/index.ts +6 -0
  144. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  145. package/src/interceptors/locusRetry.ts +25 -4
  146. package/src/interpretation/index.ts +27 -9
  147. package/src/interpretation/interpretation.types.ts +11 -0
  148. package/src/locus-info/controlsUtils.ts +3 -1
  149. package/src/locus-info/index.ts +304 -100
  150. package/src/locus-info/infoUtils.ts +41 -6
  151. package/src/locus-info/types.ts +25 -1
  152. package/src/media/index.ts +3 -0
  153. package/src/media/properties.ts +1 -0
  154. package/src/meeting/in-meeting-actions.ts +8 -0
  155. package/src/meeting/index.ts +494 -67
  156. package/src/meeting/locusMediaRequest.ts +106 -6
  157. package/src/meeting/muteState.ts +10 -1
  158. package/src/meeting/request.ts +11 -0
  159. package/src/meeting/util.ts +47 -5
  160. package/src/meeting-info/meeting-info-v2.ts +4 -2
  161. package/src/meetings/index.ts +133 -78
  162. package/src/meetings/meetings.types.ts +19 -0
  163. package/src/meetings/request.ts +43 -0
  164. package/src/meetings/util.ts +97 -1
  165. package/src/member/index.ts +10 -0
  166. package/src/member/types.ts +1 -0
  167. package/src/member/util.ts +29 -0
  168. package/src/members/index.ts +70 -7
  169. package/src/members/util.ts +12 -4
  170. package/src/metrics/constants.ts +4 -0
  171. package/src/multistream/codec/constants.ts +58 -0
  172. package/src/multistream/mediaRequestManager.ts +119 -28
  173. package/src/multistream/receiveSlot.ts +18 -0
  174. package/src/reachability/index.ts +19 -15
  175. package/src/reachability/reachability.types.ts +11 -0
  176. package/src/reachability/util.ts +36 -0
  177. package/src/reactions/reactions.type.ts +3 -0
  178. package/src/recording-controller/index.ts +1 -2
  179. package/src/webinar/index.ts +214 -36
  180. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  181. package/test/unit/spec/breakouts/breakout.ts +9 -3
  182. package/test/unit/spec/breakouts/index.ts +49 -0
  183. package/test/unit/spec/controls-options-manager/index.js +244 -29
  184. package/test/unit/spec/controls-options-manager/util.js +165 -0
  185. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  186. package/test/unit/spec/hashTree/utils.ts +125 -1
  187. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  188. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  189. package/test/unit/spec/interpretation/index.ts +26 -4
  190. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  191. package/test/unit/spec/locus-info/index.js +499 -81
  192. package/test/unit/spec/locus-info/infoUtils.js +184 -1
  193. package/test/unit/spec/media/index.ts +31 -0
  194. package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
  195. package/test/unit/spec/meeting/index.js +1615 -42
  196. package/test/unit/spec/meeting/locusMediaRequest.ts +302 -4
  197. package/test/unit/spec/meeting/muteState.js +81 -0
  198. package/test/unit/spec/meeting/request.js +12 -0
  199. package/test/unit/spec/meeting/utils.js +157 -1
  200. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  201. package/test/unit/spec/meetings/index.js +363 -36
  202. package/test/unit/spec/meetings/request.js +141 -0
  203. package/test/unit/spec/meetings/utils.js +189 -0
  204. package/test/unit/spec/member/index.js +7 -0
  205. package/test/unit/spec/member/util.js +89 -0
  206. package/test/unit/spec/members/index.js +216 -0
  207. package/test/unit/spec/members/request.js +37 -1
  208. package/test/unit/spec/members/utils.js +63 -0
  209. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  210. package/test/unit/spec/reachability/index.ts +59 -46
  211. package/test/unit/spec/reachability/util.ts +31 -0
  212. package/test/unit/spec/recording-controller/index.js +9 -8
  213. 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
  {
@@ -712,6 +883,67 @@ describe('plugin-meetings', () => {
712
883
  assert.deepEqual(args, [[uuid1], locusUrls]);
713
884
  });
714
885
  });
886
+ describe('#transfer', () => {
887
+ it('should have #transfer', () => {
888
+ assert.exists(meeting.transfer);
889
+ });
890
+
891
+ it('should transfer host using the current locus when the target member is in the current session', async () => {
892
+ sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: true});
893
+ meeting.members.transferHostToMember = sinon.stub().resolves(test1);
894
+
895
+ await meeting.transfer(uuid2, false);
896
+
897
+ assert.calledOnceWithExactly(meeting.members.transferHostToMember, uuid2, false);
898
+ });
899
+
900
+ it('should transfer host using breakout locus when the target member is joined in a breakout session', async () => {
901
+ sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: false});
902
+ meeting.members.transferHostToMember = sinon.stub().resolves(test1);
903
+ meeting.breakouts.breakouts = {
904
+ models: [
905
+ {
906
+ members: {
907
+ membersCollection: {
908
+ get: sinon.stub().withArgs(uuid2).returns({isInMeeting: true}),
909
+ },
910
+ },
911
+ breakoutRosterLocus: {url: 'https://example.com/breakout-locus'},
912
+ },
913
+ ],
914
+ };
915
+
916
+ await meeting.transfer(uuid2, false);
917
+
918
+ assert.calledOnceWithExactly(
919
+ meeting.members.transferHostToMember,
920
+ uuid2,
921
+ false,
922
+ 'https://example.com/breakout-locus'
923
+ );
924
+ });
925
+
926
+ it('should not use breakout locus when the breakout member is not in meeting', async () => {
927
+ sinon.stub(meeting.members.membersCollection, 'get').withArgs(uuid2).returns({isInMeeting: false});
928
+ meeting.members.transferHostToMember = sinon.stub().resolves(test1);
929
+ meeting.breakouts.breakouts = {
930
+ models: [
931
+ {
932
+ members: {
933
+ membersCollection: {
934
+ get: sinon.stub().withArgs(uuid2).returns({isInMeeting: false}),
935
+ },
936
+ },
937
+ breakoutRosterLocus: {url: 'https://example.com/breakout-locus'},
938
+ },
939
+ ],
940
+ };
941
+
942
+ await meeting.transfer(uuid2, false);
943
+
944
+ assert.calledOnceWithExactly(meeting.members.transferHostToMember, uuid2, false, undefined);
945
+ });
946
+ });
715
947
  describe('#getMembers', () => {
716
948
  it('should have #getMembers', () => {
717
949
  assert.exists(meeting.getMembers);
@@ -792,7 +1024,7 @@ describe('plugin-meetings', () => {
792
1024
 
793
1025
  // resets joinWithMediaRetryInfo
794
1026
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
795
- isRetry: false,
1027
+ retryCount: 0,
796
1028
  prevJoinResponse: undefined,
797
1029
  });
798
1030
  });
@@ -864,7 +1096,12 @@ describe('plugin-meetings', () => {
864
1096
  meeting.join = sinon.stub().returns(Promise.reject(error));
865
1097
  meeting.locusUrl = null; // when join fails, we end up with null locusUrl
866
1098
 
867
- await assert.isRejected(meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}}));
1099
+ const thrownError = await assert.isRejected(
1100
+ meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}})
1101
+ );
1102
+
1103
+ // should throw the first attempt's error
1104
+ assert.equal(thrownError, error);
868
1105
 
869
1106
  assert.calledTwice(abortTurnDiscoveryStub);
870
1107
 
@@ -902,11 +1139,79 @@ describe('plugin-meetings', () => {
902
1139
 
903
1140
  // resets joinWithMediaRetryInfo
904
1141
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
905
- isRetry: false,
1142
+ retryCount: 0,
1143
+ prevJoinResponse: undefined,
1144
+ firstError: undefined,
1145
+ prevError: undefined,
1146
+ });
1147
+ });
1148
+
1149
+ it('should re-join on retry when join() fails on first attempt, and throw the first error if join fails again', async () => {
1150
+ const firstJoinError = new Error('first join error');
1151
+ const secondJoinError = new Error('second join error');
1152
+
1153
+ meeting.join = sinon
1154
+ .stub()
1155
+ .onFirstCall()
1156
+ .rejects(firstJoinError)
1157
+ .onSecondCall()
1158
+ .rejects(secondJoinError);
1159
+ meeting.locusUrl = null; // join never succeeds
1160
+
1161
+ const thrownError = await assert.isRejected(
1162
+ meeting.joinWithMedia({joinOptions, mediaOptions})
1163
+ );
1164
+
1165
+ // join() should be called twice — once for the first attempt, once for the re-join
1166
+ assert.calledTwice(meeting.join);
1167
+ // TURN discovery should be attempted twice (once per join)
1168
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1169
+
1170
+ // should throw the first attempt's error, not the second
1171
+ assert.equal(thrownError, firstJoinError);
1172
+
1173
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1174
+ assert.calledWith(
1175
+ Metrics.sendBehavioralMetric.firstCall,
1176
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1177
+ sinon.match({reason: firstJoinError.message, isRetry: false}),
1178
+ sinon.match.any
1179
+ );
1180
+ assert.calledWith(
1181
+ Metrics.sendBehavioralMetric.secondCall,
1182
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1183
+ sinon.match({reason: secondJoinError.message, isRetry: true}),
1184
+ sinon.match.any
1185
+ );
1186
+
1187
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
1188
+ retryCount: 0,
906
1189
  prevJoinResponse: undefined,
1190
+ firstError: undefined,
1191
+ prevError: undefined,
907
1192
  });
908
1193
  });
909
1194
 
1195
+ it('should NOT call join() again on retry when join() succeeded but addMediaInternal() failed', async () => {
1196
+ const addMediaError = new Error('addMedia error');
1197
+
1198
+ meeting.addMediaInternal = sinon
1199
+ .stub()
1200
+ .onFirstCall()
1201
+ .rejects(addMediaError)
1202
+ .onSecondCall()
1203
+ .resolves(test4);
1204
+
1205
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1206
+
1207
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1208
+
1209
+ // join() should only be called once — the successful join result is reused on retry
1210
+ assert.calledOnce(meeting.join);
1211
+ // TURN discovery request is only generated once (on the first attempt alongside join)
1212
+ assert.calledOnce(generateTurnDiscoveryRequestMessageStub);
1213
+ });
1214
+
910
1215
  it('should resolve if join() fails the first time but succeeds the second time', async () => {
911
1216
  const error = new Error('fake');
912
1217
  meeting.join = sinon
@@ -948,7 +1253,7 @@ describe('plugin-meetings', () => {
948
1253
 
949
1254
  // resets joinWithMediaRetryInfo
950
1255
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
951
- isRetry: false,
1256
+ retryCount: 0,
952
1257
  prevJoinResponse: undefined,
953
1258
  });
954
1259
  });
@@ -1017,6 +1322,127 @@ describe('plugin-meetings', () => {
1017
1322
  );
1018
1323
  });
1019
1324
 
1325
+ it('should throw the first attempt error when retry also fails with a different error', async () => {
1326
+ const firstError = new Error('first attempt error');
1327
+ const secondError = new Error('second attempt error');
1328
+
1329
+ const addMediaInternalResults = [];
1330
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1331
+ const defer = new Defer();
1332
+ addMediaInternalResults.push(defer);
1333
+ return defer.promise;
1334
+ });
1335
+
1336
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1337
+
1338
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1339
+
1340
+ await testUtils.flushPromises();
1341
+
1342
+ // 1st attempt fails
1343
+ addMediaInternalResults[0].reject(firstError);
1344
+ await testUtils.flushPromises();
1345
+
1346
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1347
+ assert.notCalled(leaveStub);
1348
+
1349
+ // 2nd (final) attempt fails
1350
+ addMediaInternalResults[1].reject(secondError);
1351
+ const thrownError = await assert.isRejected(result);
1352
+
1353
+ // should throw the first error, not the second
1354
+ assert.equal(thrownError, firstError);
1355
+
1356
+ // leave() should only be called after the last (2nd) attempt
1357
+ assert.calledOnce(leaveStub);
1358
+
1359
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1360
+ });
1361
+
1362
+ it('should throw the UserNotJoinedError as firstError when it occurs on the 1st attempt and 2nd attempt fails differently', async () => {
1363
+ const userNotJoinedError = new UserNotJoinedError();
1364
+ const secondError = new Error('second attempt error');
1365
+
1366
+ const addMediaInternalResults = [];
1367
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1368
+ const defer = new Defer();
1369
+ addMediaInternalResults.push(defer);
1370
+ return defer.promise;
1371
+ });
1372
+
1373
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1374
+
1375
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1376
+
1377
+ await testUtils.flushPromises();
1378
+
1379
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1380
+ addMediaInternalResults[0].reject(userNotJoinedError);
1381
+ await testUtils.flushPromises();
1382
+
1383
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1384
+ assert.notCalled(leaveStub);
1385
+
1386
+ // 2nd (final) attempt fails
1387
+ addMediaInternalResults[1].reject(secondError);
1388
+ const thrownError = await assert.isRejected(result);
1389
+
1390
+ // should throw the first (UserNotJoinedError), not the second
1391
+ assert.equal(thrownError, userNotJoinedError);
1392
+
1393
+ // join() called twice: original + re-join triggered by UserNotJoinedError in prevError
1394
+ assert.calledTwice(meeting.join);
1395
+ // leave() should only be called after the last (2nd) attempt
1396
+ assert.calledOnce(leaveStub);
1397
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1398
+ });
1399
+
1400
+ it('should throw the first UserNotJoinedError when it occurs on both 1st and 2nd attempts and a 3rd attempt also fails', async () => {
1401
+ const firstUserNotJoinedError = new UserNotJoinedError('first');
1402
+ const secondUserNotJoinedError = new UserNotJoinedError('second');
1403
+ const thirdError = new Error('third attempt error');
1404
+
1405
+ const addMediaInternalResults = [];
1406
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1407
+ const defer = new Defer();
1408
+ addMediaInternalResults.push(defer);
1409
+ return defer.promise;
1410
+ });
1411
+
1412
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1413
+
1414
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1415
+
1416
+ await testUtils.flushPromises();
1417
+
1418
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1419
+ addMediaInternalResults[0].reject(firstUserNotJoinedError);
1420
+ await testUtils.flushPromises();
1421
+
1422
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1423
+ assert.notCalled(leaveStub);
1424
+
1425
+ // 2nd attempt fails with UserNotJoinedError again — triggers another re-join
1426
+ addMediaInternalResults[1].reject(secondUserNotJoinedError);
1427
+ await testUtils.flushPromises();
1428
+
1429
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1430
+ assert.notCalled(leaveStub);
1431
+
1432
+ // 3rd (final) attempt fails
1433
+ addMediaInternalResults[2].reject(thirdError);
1434
+ const thrownError = await assert.isRejected(result);
1435
+
1436
+ // should throw the very first error across all 3 attempts
1437
+ assert.equal(thrownError, firstUserNotJoinedError);
1438
+
1439
+ // join() called 3 times: original + 2 re-joins triggered by prevError being UserNotJoinedError
1440
+ assert.calledThrice(meeting.join);
1441
+ // leave() should only be called after the last (3rd) attempt
1442
+ assert.calledOnce(leaveStub);
1443
+ assert.calledThrice(Metrics.sendBehavioralMetric);
1444
+ });
1445
+
1020
1446
  it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1021
1447
  const addMediaError = new Error('fake addMedia error');
1022
1448
  addMediaError.name = 'TypeError'; // This makes it a browser media error
@@ -1264,7 +1690,8 @@ describe('plugin-meetings', () => {
1264
1690
 
1265
1691
  await testUtils.flushPromises();
1266
1692
 
1267
- // check the callback works correctly on the 2nd attempt
1693
+ // check the callback works correctly on the 2nd attempt:
1694
+ // retryCount=1 with a non-UserNotJoinedError is terminal, so icePhase must be JOIN_MEETING_FINAL
1268
1695
  assert.equal(icePhaseCallbacks.length, 2);
1269
1696
  assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1270
1697
 
@@ -1274,6 +1701,118 @@ describe('plugin-meetings', () => {
1274
1701
  await assert.isRejected(result);
1275
1702
  });
1276
1703
 
1704
+ it('should allow an additional retry when UserNotJoinedError occurs and return JOIN_MEETING_FINAL on the 3rd attempt', async () => {
1705
+ const genericError = new Error('generic error');
1706
+ const userNotJoinedError = new UserNotJoinedError();
1707
+ const thirdError = new Error('third error');
1708
+
1709
+ const icePhaseCallbacks = [];
1710
+ const addMediaInternalResults = [];
1711
+
1712
+ meeting.addMediaInternal = sinon
1713
+ .stub()
1714
+ .callsFake((icePhaseCallback) => {
1715
+ const defer = new Defer();
1716
+
1717
+ icePhaseCallbacks.push(icePhaseCallback);
1718
+ addMediaInternalResults.push(defer);
1719
+ return defer.promise;
1720
+ });
1721
+
1722
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1723
+
1724
+ const result = meeting.joinWithMedia({
1725
+ joinOptions,
1726
+ mediaOptions,
1727
+ });
1728
+
1729
+ await testUtils.flushPromises();
1730
+
1731
+ // 1st attempt: retryCount=0 → JOIN_MEETING_RETRY
1732
+ assert.equal(icePhaseCallbacks.length, 1);
1733
+ assert.equal(icePhaseCallbacks[0](), 'JOIN_MEETING_RETRY');
1734
+ addMediaInternalResults[0].reject(genericError);
1735
+
1736
+ await testUtils.flushPromises();
1737
+
1738
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1739
+ assert.notCalled(leaveStub);
1740
+
1741
+ // 2nd attempt: retryCount=1 → JOIN_MEETING_FINAL
1742
+ // (In real usage this callback is never invoked when UserNotJoinedError is thrown,
1743
+ // because UserNotJoinedError is thrown before waitForMediaConnectionConnected() is reached.)
1744
+ assert.equal(icePhaseCallbacks.length, 2);
1745
+ assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1746
+ addMediaInternalResults[1].reject(userNotJoinedError);
1747
+
1748
+ await testUtils.flushPromises();
1749
+
1750
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1751
+ assert.notCalled(leaveStub);
1752
+
1753
+ // 3rd attempt: retryCount=2 → JOIN_MEETING_FINAL
1754
+ assert.equal(icePhaseCallbacks.length, 3);
1755
+ assert.equal(icePhaseCallbacks[2](), 'JOIN_MEETING_FINAL');
1756
+ addMediaInternalResults[2].reject(thirdError);
1757
+
1758
+ const thrownError = await assert.isRejected(result);
1759
+
1760
+ // should throw the first error
1761
+ assert.equal(thrownError, genericError);
1762
+
1763
+ // leave() should only be called once, after the last (3rd) attempt
1764
+ assert.calledOnce(leaveStub);
1765
+ });
1766
+
1767
+ it('should re-join when retrying after a UserNotJoinedError', async () => {
1768
+ const userNotJoinedError = new UserNotJoinedError();
1769
+
1770
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1771
+
1772
+ meeting.addMediaInternal = sinon
1773
+ .stub()
1774
+ .onFirstCall()
1775
+ .rejects(userNotJoinedError)
1776
+ .onSecondCall()
1777
+ .resolves(test4);
1778
+
1779
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1780
+
1781
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1782
+
1783
+ // join() should be called twice — once for the first attempt, once for the re-join after UserNotJoinedError
1784
+ assert.calledTwice(meeting.join);
1785
+ // TURN discovery should be attempted twice (once per join)
1786
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1787
+ // leave() should never be called when retrying after UserNotJoinedError
1788
+ assert.notCalled(leaveStub);
1789
+ });
1790
+
1791
+ it('should re-join when isUserInLeftState returns true on retry', async () => {
1792
+ const addMediaError = new Error('addMedia error');
1793
+
1794
+ sinon.stub(MeetingUtil, 'isUserInLeftState').returns(true);
1795
+
1796
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1797
+
1798
+ meeting.addMediaInternal = sinon
1799
+ .stub()
1800
+ .onFirstCall()
1801
+ .rejects(addMediaError)
1802
+ .onSecondCall()
1803
+ .resolves(test4);
1804
+
1805
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1806
+
1807
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1808
+
1809
+ // join() should be called twice — once for the first attempt, once because the user is in left state
1810
+ assert.calledTwice(meeting.join);
1811
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1812
+ // leave() should never be called when retrying after isUserInLeftState returns true
1813
+ assert.notCalled(leaveStub);
1814
+ });
1815
+
1277
1816
  [
1278
1817
  {
1279
1818
  errorName: 'SdpOfferCreationError',
@@ -1977,6 +2516,113 @@ describe('plugin-meetings', () => {
1977
2516
  fakeProcessedReaction
1978
2517
  );
1979
2518
  });
2519
+
2520
+ [
2521
+ {
2522
+ title: 'should skip a reaction when the default relay route does not match the LLM binding',
2523
+ isPracticeSessionConnected: false,
2524
+ route: 'wrong-default-route',
2525
+ defaultBinding: 'default-route',
2526
+ practiceBinding: 'practice-route',
2527
+ shouldProcess: false,
2528
+ expectedSessionLabel: 'default session',
2529
+ },
2530
+ {
2531
+ title: 'should process a reaction when the default relay route matches the LLM binding',
2532
+ isPracticeSessionConnected: false,
2533
+ route: 'default-route',
2534
+ defaultBinding: 'default-route',
2535
+ practiceBinding: 'practice-route',
2536
+ shouldProcess: true,
2537
+ },
2538
+ {
2539
+ title:
2540
+ 'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
2541
+ isPracticeSessionConnected: true,
2542
+ route: 'practice-route',
2543
+ defaultBinding: 'default-route',
2544
+ practiceBinding: 'practice-route',
2545
+ shouldProcess: true,
2546
+ },
2547
+ {
2548
+ title:
2549
+ 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
2550
+ isPracticeSessionConnected: true,
2551
+ route: 'default-route',
2552
+ defaultBinding: 'default-route',
2553
+ practiceBinding: 'practice-route',
2554
+ shouldProcess: false,
2555
+ expectedSessionLabel: 'practice session',
2556
+ },
2557
+ ].forEach(
2558
+ ({
2559
+ title,
2560
+ isPracticeSessionConnected,
2561
+ route,
2562
+ defaultBinding,
2563
+ practiceBinding,
2564
+ shouldProcess,
2565
+ expectedSessionLabel,
2566
+ }) => {
2567
+ it(title, () => {
2568
+ meeting.isReactionsSupported = sinon.stub().returns(true);
2569
+ meeting.config.receiveReactions = true;
2570
+ const fakeSendersName = 'Fake reactors name';
2571
+ meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
2572
+ webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
2573
+ return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
2574
+ });
2575
+ webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
2576
+ if (llmSessionId === LLM_PRACTICE_SESSION) {
2577
+ return practiceBinding;
2578
+ }
2579
+
2580
+ return defaultBinding;
2581
+ });
2582
+ const fakeReactionPayload = {
2583
+ type: 'fake_type',
2584
+ codepoints: 'fake_codepoints',
2585
+ shortcodes: 'fake_shortcodes',
2586
+ };
2587
+ const fakeSenderPayload = {
2588
+ participantId: 'fake_participant_id',
2589
+ };
2590
+ const fakeRelayEvent = {
2591
+ headers: {route},
2592
+ data: {
2593
+ relayType: REACTION_RELAY_TYPES.REACTION,
2594
+ reaction: fakeReactionPayload,
2595
+ sender: fakeSenderPayload,
2596
+ },
2597
+ };
2598
+ const fakeProcessedReaction = {
2599
+ reaction: fakeReactionPayload,
2600
+ sender: {
2601
+ id: fakeSenderPayload.participantId,
2602
+ name: fakeSendersName,
2603
+ },
2604
+ };
2605
+
2606
+ TriggerProxy.trigger.resetHistory();
2607
+ meeting.processRelayEvent(fakeRelayEvent);
2608
+
2609
+ if (shouldProcess) {
2610
+ assert.calledWith(
2611
+ TriggerProxy.trigger,
2612
+ sinon.match.instanceOf(Meeting),
2613
+ {
2614
+ file: 'meeting/index',
2615
+ function: 'join',
2616
+ },
2617
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
2618
+ fakeProcessedReaction
2619
+ );
2620
+ } else {
2621
+ assert.notCalled(TriggerProxy.trigger);
2622
+ }
2623
+ });
2624
+ }
2625
+ );
1980
2626
  });
1981
2627
 
1982
2628
  describe('#handleLLMOnline', () => {
@@ -2016,6 +2662,14 @@ describe('plugin-meetings', () => {
2016
2662
 
2017
2663
  assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2018
2664
  });
2665
+
2666
+ it('calls syncAllHashTreeDatasets on locusInfo', () => {
2667
+ sinon.stub(meeting.locusInfo, 'syncAllHashTreeDatasets').resolves();
2668
+
2669
+ meeting.handleLLMOnline();
2670
+
2671
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
2672
+ });
2019
2673
  });
2020
2674
 
2021
2675
  describe('#join', () => {
@@ -2096,6 +2750,8 @@ describe('plugin-meetings', () => {
2096
2750
  regionCode: 'EU',
2097
2751
  },
2098
2752
  preferTranscoding: !enableMultistream,
2753
+ getCurrentSelfUrl: sinon.match.func,
2754
+ waitForSelfUrlChange: sinon.match.func,
2099
2755
  },
2100
2756
  {
2101
2757
  parent: meeting.webex,
@@ -4534,6 +5190,297 @@ describe('plugin-meetings', () => {
4534
5190
  },
4535
5191
  });
4536
5192
  });
5193
+
5194
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
5195
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
5196
+ const fakeStats = new Map([
5197
+ [
5198
+ 'transport-1',
5199
+ {
5200
+ type: 'transport',
5201
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5202
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
5203
+ },
5204
+ ],
5205
+ [
5206
+ 'outbound-rtp-1',
5207
+ {
5208
+ type: 'outbound-rtp',
5209
+ ssrc: 12345,
5210
+ },
5211
+ ],
5212
+ ]);
5213
+
5214
+ statsAnalyzerStub.emit(
5215
+ {file: 'test', function: 'test'},
5216
+ StatsAnalyzerEventNames.STATS_UPDATE,
5217
+ {stats: fakeStats}
5218
+ );
5219
+
5220
+ assert.calledWith(
5221
+ TriggerProxy.trigger,
5222
+ sinon.match.instanceOf(Meeting),
5223
+ {
5224
+ file: 'meeting/index',
5225
+ function: 'setupStatsAnalyzerEventHandlers',
5226
+ },
5227
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5228
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5229
+ );
5230
+
5231
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5232
+ });
5233
+
5234
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
5235
+ const firstStats = new Map([
5236
+ [
5237
+ 'transport-1',
5238
+ {
5239
+ type: 'transport',
5240
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5241
+ },
5242
+ ],
5243
+ ]);
5244
+
5245
+ statsAnalyzerStub.emit(
5246
+ {file: 'test', function: 'test'},
5247
+ StatsAnalyzerEventNames.STATS_UPDATE,
5248
+ {stats: firstStats}
5249
+ );
5250
+
5251
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5252
+
5253
+ const secondStats = new Map([
5254
+ [
5255
+ 'transport-1',
5256
+ {
5257
+ type: 'transport',
5258
+ srtpCipher: 'AEAD_AES_256_GCM',
5259
+ },
5260
+ ],
5261
+ ]);
5262
+
5263
+ TriggerProxy.trigger.resetHistory();
5264
+
5265
+ statsAnalyzerStub.emit(
5266
+ {file: 'test', function: 'test'},
5267
+ StatsAnalyzerEventNames.STATS_UPDATE,
5268
+ {stats: secondStats}
5269
+ );
5270
+
5271
+ assert.calledWith(
5272
+ TriggerProxy.trigger,
5273
+ sinon.match.instanceOf(Meeting),
5274
+ {
5275
+ file: 'meeting/index',
5276
+ function: 'setupStatsAnalyzerEventHandlers',
5277
+ },
5278
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5279
+ {srtpCipher: 'AEAD_AES_256_GCM'}
5280
+ );
5281
+
5282
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
5283
+ });
5284
+
5285
+ it('does not emit event when srtpCipher has not changed', async () => {
5286
+ const firstStats = new Map([
5287
+ [
5288
+ 'transport-1',
5289
+ {
5290
+ type: 'transport',
5291
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5292
+ },
5293
+ ],
5294
+ ]);
5295
+
5296
+ statsAnalyzerStub.emit(
5297
+ {file: 'test', function: 'test'},
5298
+ StatsAnalyzerEventNames.STATS_UPDATE,
5299
+ {stats: firstStats}
5300
+ );
5301
+
5302
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5303
+
5304
+ TriggerProxy.trigger.resetHistory();
5305
+
5306
+ // Emit same cipher again
5307
+ statsAnalyzerStub.emit(
5308
+ {file: 'test', function: 'test'},
5309
+ StatsAnalyzerEventNames.STATS_UPDATE,
5310
+ {stats: firstStats}
5311
+ );
5312
+
5313
+ // Should not trigger event again
5314
+ assert.neverCalledWith(
5315
+ TriggerProxy.trigger,
5316
+ sinon.match.instanceOf(Meeting),
5317
+ sinon.match.any,
5318
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5319
+ sinon.match.any
5320
+ );
5321
+
5322
+ // Cipher should remain the same
5323
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5324
+ });
5325
+
5326
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
5327
+ const fakeStats = new Map([
5328
+ [
5329
+ 'outbound-rtp-1',
5330
+ {
5331
+ type: 'outbound-rtp',
5332
+ ssrc: 12345,
5333
+ },
5334
+ ],
5335
+ [
5336
+ 'inbound-rtp-1',
5337
+ {
5338
+ type: 'inbound-rtp',
5339
+ ssrc: 67890,
5340
+ },
5341
+ ],
5342
+ ]);
5343
+
5344
+ statsAnalyzerStub.emit(
5345
+ {file: 'test', function: 'test'},
5346
+ StatsAnalyzerEventNames.STATS_UPDATE,
5347
+ {stats: fakeStats}
5348
+ );
5349
+
5350
+ assert.neverCalledWith(
5351
+ TriggerProxy.trigger,
5352
+ sinon.match.instanceOf(Meeting),
5353
+ sinon.match.any,
5354
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5355
+ sinon.match.any
5356
+ );
5357
+
5358
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5359
+ });
5360
+
5361
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
5362
+ const fakeStats = new Map([
5363
+ [
5364
+ 'transport-1',
5365
+ {
5366
+ type: 'transport',
5367
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
5368
+ // no srtpCipher property
5369
+ },
5370
+ ],
5371
+ ]);
5372
+
5373
+ statsAnalyzerStub.emit(
5374
+ {file: 'test', function: 'test'},
5375
+ StatsAnalyzerEventNames.STATS_UPDATE,
5376
+ {stats: fakeStats}
5377
+ );
5378
+
5379
+ assert.neverCalledWith(
5380
+ TriggerProxy.trigger,
5381
+ sinon.match.instanceOf(Meeting),
5382
+ sinon.match.any,
5383
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5384
+ sinon.match.any
5385
+ );
5386
+
5387
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5388
+ });
5389
+
5390
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
5391
+ const fakeStats = new Map([
5392
+ [
5393
+ 'transport-1',
5394
+ {
5395
+ type: 'transport',
5396
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5397
+ },
5398
+ ],
5399
+ [
5400
+ 'transport-2',
5401
+ {
5402
+ type: 'transport',
5403
+ srtpCipher: 'AEAD_AES_256_GCM',
5404
+ },
5405
+ ],
5406
+ [
5407
+ 'outbound-rtp-1',
5408
+ {
5409
+ type: 'outbound-rtp',
5410
+ ssrc: 12345,
5411
+ },
5412
+ ],
5413
+ ]);
5414
+
5415
+ statsAnalyzerStub.emit(
5416
+ {file: 'test', function: 'test'},
5417
+ StatsAnalyzerEventNames.STATS_UPDATE,
5418
+ {stats: fakeStats}
5419
+ );
5420
+
5421
+ assert.calledWith(
5422
+ TriggerProxy.trigger,
5423
+ sinon.match.instanceOf(Meeting),
5424
+ {
5425
+ file: 'meeting/index',
5426
+ function: 'setupStatsAnalyzerEventHandlers',
5427
+ },
5428
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5429
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5430
+ );
5431
+
5432
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5433
+ });
5434
+
5435
+ it('handles empty stats map without errors', async () => {
5436
+ const emptyStats = new Map();
5437
+
5438
+ statsAnalyzerStub.emit(
5439
+ {file: 'test', function: 'test'},
5440
+ StatsAnalyzerEventNames.STATS_UPDATE,
5441
+ {stats: emptyStats}
5442
+ );
5443
+
5444
+ assert.neverCalledWith(
5445
+ TriggerProxy.trigger,
5446
+ sinon.match.instanceOf(Meeting),
5447
+ sinon.match.any,
5448
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5449
+ sinon.match.any
5450
+ );
5451
+
5452
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5453
+ });
5454
+
5455
+ it('logs cipher change when cipher is updated', async () => {
5456
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
5457
+
5458
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
5459
+
5460
+ const newStats = new Map([
5461
+ [
5462
+ 'transport-1',
5463
+ {
5464
+ type: 'transport',
5465
+ srtpCipher: 'AEAD_AES_256_GCM',
5466
+ },
5467
+ ],
5468
+ ]);
5469
+
5470
+ statsAnalyzerStub.emit(
5471
+ {file: 'test', function: 'test'},
5472
+ StatsAnalyzerEventNames.STATS_UPDATE,
5473
+ {stats: newStats}
5474
+ );
5475
+
5476
+ assert.calledWithMatch(
5477
+ loggerSpy,
5478
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
5479
+ );
5480
+
5481
+ loggerSpy.restore();
5482
+ });
5483
+ });
4537
5484
  });
4538
5485
 
4539
5486
  describe('handles StatsMonitor events', () => {
@@ -6429,6 +7376,9 @@ describe('plugin-meetings', () => {
6429
7376
 
6430
7377
  meeting.annotation.deregisterEvents = sinon.stub();
6431
7378
  webex.internal.llm.off = sinon.stub();
7379
+ webex.internal.mercury.off = sinon.stub();
7380
+ meeting.mercuryOnlineHandler = sinon.stub();
7381
+ meeting.mercuryOfflineHandler = sinon.stub();
6432
7382
 
6433
7383
  // A meeting needs to be joined to leave
6434
7384
  meeting.meetingState = 'ACTIVE';
@@ -6452,6 +7402,67 @@ describe('plugin-meetings', () => {
6452
7402
  assert.calledOnce(meeting.clearMeetingData);
6453
7403
  });
6454
7404
 
7405
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
7406
+ const onlineHandler = meeting.mercuryOnlineHandler;
7407
+ const offlineHandler = meeting.mercuryOfflineHandler;
7408
+
7409
+ await meeting.leave();
7410
+
7411
+ // All llm/mercury consumers (direct listeners, voicea transcription,
7412
+ // annotation) must be detached before the /leave request so that
7413
+ // in-flight events do not trigger unnecessary Locus syncs
7414
+ // (per Locus team recommendation).
7415
+ assert.callOrder(
7416
+ webex.internal.llm.off,
7417
+ webex.internal.mercury.off,
7418
+ meeting.stopTranscription,
7419
+ meeting.annotation.deregisterEvents,
7420
+ meeting.meetingRequest.leaveMeeting
7421
+ );
7422
+ assert.calledWithExactly(
7423
+ webex.internal.llm.off,
7424
+ 'event:relay.event',
7425
+ meeting.processRelayEvent
7426
+ );
7427
+ assert.calledWithExactly(
7428
+ webex.internal.llm.off,
7429
+ LOCUS_LLM_EVENT,
7430
+ meeting.processLocusLLMEvent
7431
+ );
7432
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7433
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7434
+ assert.isUndefined(meeting.mercuryOnlineHandler);
7435
+ assert.isUndefined(meeting.mercuryOfflineHandler);
7436
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7437
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7438
+ assert.isUndefined(meeting.transcription);
7439
+ });
7440
+
7441
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
7442
+ const onlineHandler = meeting.mercuryOnlineHandler;
7443
+ const offlineHandler = meeting.mercuryOfflineHandler;
7444
+ meeting.meetingRequest.leaveMeeting = sinon
7445
+ .stub()
7446
+ .returns(Promise.reject(new Error('leave failed')));
7447
+
7448
+ await meeting.leave().catch(() => {});
7449
+
7450
+ assert.calledWithExactly(
7451
+ webex.internal.llm.off,
7452
+ 'event:relay.event',
7453
+ meeting.processRelayEvent
7454
+ );
7455
+ assert.calledWithExactly(
7456
+ webex.internal.llm.off,
7457
+ LOCUS_LLM_EVENT,
7458
+ meeting.processLocusLLMEvent
7459
+ );
7460
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7461
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7462
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7463
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7464
+ });
7465
+
6455
7466
  it('should reset call diagnostic latencies correctly', async () => {
6456
7467
  const leave = meeting.leave();
6457
7468
 
@@ -8459,6 +9470,9 @@ describe('plugin-meetings', () => {
8459
9470
 
8460
9471
  meeting.annotation.deregisterEvents = sinon.stub();
8461
9472
  webex.internal.llm.off = sinon.stub();
9473
+ webex.internal.mercury.off = sinon.stub();
9474
+ meeting.mercuryOnlineHandler = sinon.stub();
9475
+ meeting.mercuryOfflineHandler = sinon.stub();
8462
9476
 
8463
9477
  // A meeting needs to be joined to end
8464
9478
  meeting.meetingState = 'ACTIVE';
@@ -8481,6 +9495,66 @@ describe('plugin-meetings', () => {
8481
9495
  assert.calledOnce(meeting?.unsetPeerConnections);
8482
9496
  assert.calledOnce(meeting?.clearMeetingData);
8483
9497
  });
9498
+
9499
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
9500
+ const onlineHandler = meeting.mercuryOnlineHandler;
9501
+ const offlineHandler = meeting.mercuryOfflineHandler;
9502
+
9503
+ await meeting.endMeetingForAll();
9504
+
9505
+ // All llm/mercury consumers (direct listeners, voicea transcription,
9506
+ // annotation) must be detached before the /end request so that
9507
+ // in-flight events do not trigger unnecessary Locus syncs
9508
+ // (per Locus team recommendation).
9509
+ assert.callOrder(
9510
+ webex.internal.llm.off,
9511
+ webex.internal.mercury.off,
9512
+ meeting.stopTranscription,
9513
+ meeting.annotation.deregisterEvents,
9514
+ meeting.meetingRequest.endMeetingForAll
9515
+ );
9516
+ assert.calledWithExactly(
9517
+ webex.internal.llm.off,
9518
+ 'event:relay.event',
9519
+ meeting.processRelayEvent
9520
+ );
9521
+ assert.calledWithExactly(
9522
+ webex.internal.llm.off,
9523
+ LOCUS_LLM_EVENT,
9524
+ meeting.processLocusLLMEvent
9525
+ );
9526
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9527
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9528
+ assert.isUndefined(meeting.mercuryOnlineHandler);
9529
+ assert.isUndefined(meeting.mercuryOfflineHandler);
9530
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9531
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9532
+ });
9533
+
9534
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
9535
+ const onlineHandler = meeting.mercuryOnlineHandler;
9536
+ const offlineHandler = meeting.mercuryOfflineHandler;
9537
+ meeting.meetingRequest.endMeetingForAll = sinon
9538
+ .stub()
9539
+ .returns(Promise.reject(new Error('end failed')));
9540
+
9541
+ await meeting.endMeetingForAll().catch(() => {});
9542
+
9543
+ assert.calledWithExactly(
9544
+ webex.internal.llm.off,
9545
+ 'event:relay.event',
9546
+ meeting.processRelayEvent
9547
+ );
9548
+ assert.calledWithExactly(
9549
+ webex.internal.llm.off,
9550
+ LOCUS_LLM_EVENT,
9551
+ meeting.processLocusLLMEvent
9552
+ );
9553
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9554
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9555
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9556
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9557
+ });
8484
9558
  });
8485
9559
 
8486
9560
  describe('#moveTo', () => {
@@ -10417,7 +11491,7 @@ describe('plugin-meetings', () => {
10417
11491
  );
10418
11492
  done();
10419
11493
  });
10420
- it('listens to the self admitted guest event without blocking on token prefetch', async () => {
11494
+ it('listens to the self admitted guest event and waits for token prefetch before reconnecting LLM', async () => {
10421
11495
  meeting.stopKeepAlive = sinon.stub();
10422
11496
  meeting.updateLLMConnection = sinon.stub();
10423
11497
  let resolvePrefetch;
@@ -10443,7 +11517,7 @@ describe('plugin-meetings', () => {
10443
11517
  'meeting:self:guestAdmitted',
10444
11518
  {payload: test1}
10445
11519
  );
10446
- assert.calledOnce(meeting.updateLLMConnection);
11520
+ assert.notCalled(meeting.updateLLMConnection);
10447
11521
  assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
10448
11522
 
10449
11523
  assert.calledOnceWithExactly(
@@ -10456,6 +11530,7 @@ describe('plugin-meetings', () => {
10456
11530
 
10457
11531
  resolvePrefetch(false);
10458
11532
  await Promise.resolve();
11533
+ await Promise.resolve();
10459
11534
 
10460
11535
  assert.calledOnce(meeting.updateLLMConnection);
10461
11536
  });
@@ -10971,6 +12046,92 @@ describe('plugin-meetings', () => {
10971
12046
  );
10972
12047
  });
10973
12048
 
12049
+ const recordingTestCases = [
12050
+ {
12051
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
12052
+ state: RECORDING_STATE.RECORDING,
12053
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
12054
+ expectedRecordingState: RECORDING_STATE.RECORDING,
12055
+ },
12056
+ {
12057
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
12058
+ state: RECORDING_STATE.IDLE,
12059
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
12060
+ expectedRecordingState: RECORDING_STATE.IDLE,
12061
+ },
12062
+ {
12063
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
12064
+ state: RECORDING_STATE.PAUSED,
12065
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
12066
+ expectedRecordingState: RECORDING_STATE.PAUSED,
12067
+ },
12068
+ {
12069
+ description:
12070
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
12071
+ state: RECORDING_STATE.RESUMED,
12072
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
12073
+ expectedRecordingState: RECORDING_STATE.RECORDING,
12074
+ },
12075
+ ];
12076
+
12077
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
12078
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
12079
+ const modifiedBy = 'user-id-123';
12080
+ const lastModified = '2026-01-01T00:00:00Z';
12081
+
12082
+ await meeting.locusInfo.emitScoped(
12083
+ {function: 'test', file: 'test'},
12084
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
12085
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
12086
+ );
12087
+
12088
+ assert.deepEqual(meeting.recording, {
12089
+ state: expectedRecordingState,
12090
+ modifiedBy,
12091
+ lastModified,
12092
+ modifiedByServiceAppName: undefined,
12093
+ modifiedByServiceAppId: undefined,
12094
+ });
12095
+
12096
+ assert.calledWith(
12097
+ TriggerProxy.trigger,
12098
+ meeting,
12099
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
12100
+ expectedEvent,
12101
+ meeting.recording
12102
+ );
12103
+ });
12104
+ });
12105
+
12106
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
12107
+ const modifiedBy = 'user-id-123';
12108
+ const lastModified = '2026-01-01T00:00:00Z';
12109
+ const modifiedByServiceAppName = 'My Bot';
12110
+ const modifiedByServiceAppId = 'app-id-123';
12111
+
12112
+ await meeting.locusInfo.emitScoped(
12113
+ {function: 'test', file: 'test'},
12114
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
12115
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
12116
+ );
12117
+
12118
+ assert.deepEqual(meeting.recording, {
12119
+ state: RECORDING_STATE.RECORDING,
12120
+ modifiedBy,
12121
+ lastModified,
12122
+ modifiedByServiceAppName,
12123
+ modifiedByServiceAppId,
12124
+ });
12125
+
12126
+ assert.calledWith(
12127
+ TriggerProxy.trigger,
12128
+ meeting,
12129
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
12130
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
12131
+ meeting.recording
12132
+ );
12133
+ });
12134
+
10974
12135
  it('listens to the locus interpretation update event', () => {
10975
12136
  const interpretation = {
10976
12137
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11024,6 +12185,7 @@ describe('plugin-meetings', () => {
11024
12185
  meeting.annotation.locusUrlUpdate = sinon.stub();
11025
12186
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11026
12187
  meeting.webinar.locusUrlUpdate = sinon.stub();
12188
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11027
12189
 
11028
12190
  meeting.locusInfo.emit(
11029
12191
  {function: 'test', file: 'test'},
@@ -11038,6 +12200,7 @@ describe('plugin-meetings', () => {
11038
12200
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11039
12201
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11040
12202
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
12203
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11041
12204
  assert.equal(meeting.locusUrl, newLocusUrl);
11042
12205
  assert(meeting.locusId, '12345');
11043
12206
 
@@ -11353,6 +12516,109 @@ describe('plugin-meetings', () => {
11353
12516
  });
11354
12517
  });
11355
12518
 
12519
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
12520
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
12521
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12522
+
12523
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
12524
+ meeting.destination = {info: {topic: 'old'}};
12525
+
12526
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12527
+
12528
+ assert.equal(meeting.destination, syncedLocus);
12529
+ });
12530
+
12531
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
12532
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12533
+ const originalDestination = {destination: 'original-destination'};
12534
+
12535
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
12536
+ meeting.destination = originalDestination;
12537
+
12538
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12539
+
12540
+ assert.equal(meeting.destination, originalDestination);
12541
+ });
12542
+
12543
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
12544
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12545
+
12546
+ meeting.meetingInfo = {};
12547
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12548
+
12549
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12550
+
12551
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
12552
+ });
12553
+
12554
+ it('does not fetch meeting info when destination has no info', () => {
12555
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12556
+
12557
+ meeting.meetingInfo = {};
12558
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
12559
+
12560
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12561
+
12562
+ assert.notCalled(fetchMeetingInfoStub);
12563
+ });
12564
+
12565
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
12566
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12567
+
12568
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
12569
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12570
+
12571
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12572
+
12573
+ assert.notCalled(fetchMeetingInfoStub);
12574
+ });
12575
+
12576
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
12577
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12578
+
12579
+ meeting.meetingInfo = {};
12580
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12581
+ meeting.fetchMeetingInfoTimeoutId = 42;
12582
+
12583
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12584
+
12585
+ assert.notCalled(fetchMeetingInfoStub);
12586
+ });
12587
+
12588
+ ['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
12589
+ it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
12590
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12591
+
12592
+ meeting.meetingInfo = {};
12593
+ meeting.destination = {
12594
+ url: 'https://locus.example.com/locus/123',
12595
+ info: {topic: 'x'},
12596
+ };
12597
+
12598
+ meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
12599
+
12600
+ assert.notCalled(fetchMeetingInfoStub);
12601
+ });
12602
+ });
12603
+
12604
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
12605
+ const error = new Error('fetch failed');
12606
+
12607
+ meeting.meetingInfo = {};
12608
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12609
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
12610
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
12611
+
12612
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
12613
+
12614
+ assert.calledOnce(loggerInfoStub);
12615
+ assert.match(
12616
+ loggerInfoStub.firstCall.args[0],
12617
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
12618
+ );
12619
+ });
12620
+ });
12621
+
11356
12622
  describe('#emailInput', () => {
11357
12623
  it('should set the email input', () => {
11358
12624
  assert.notOk(meeting.emailInput);
@@ -11955,6 +13221,7 @@ describe('plugin-meetings', () => {
11955
13221
  let showAutoEndMeetingWarningSpy;
11956
13222
  let canAttendeeRequestAiAssistantEnabledSpy;
11957
13223
  let attendeeRequestAiAssistantDeclinedAllSpy;
13224
+ let isAnonymizeDisplayNamesEnabledSpy;
11958
13225
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11959
13226
 
11960
13227
  beforeEach(() => {
@@ -12003,6 +13270,10 @@ describe('plugin-meetings', () => {
12003
13270
  MeetingUtil,
12004
13271
  'attendeeRequestAiAssistantDeclinedAll'
12005
13272
  );
13273
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
13274
+ MeetingUtil,
13275
+ 'isAnonymizeDisplayNamesEnabled'
13276
+ );
12006
13277
  });
12007
13278
 
12008
13279
  afterEach(() => {
@@ -12011,6 +13282,7 @@ describe('plugin-meetings', () => {
12011
13282
  showAutoEndMeetingWarningSpy.restore();
12012
13283
  canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
13284
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
13285
+ isAnonymizeDisplayNamesEnabledSpy.restore();
12014
13286
  });
12015
13287
 
12016
13288
  forEach(
@@ -12568,6 +13840,7 @@ describe('plugin-meetings', () => {
12568
13840
  meeting.roles
12569
13841
  );
12570
13842
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13843
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12571
13844
 
12572
13845
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12573
13846
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12782,6 +14055,10 @@ describe('plugin-meetings', () => {
12782
14055
  describe('#saveDataChannelToken', () => {
12783
14056
  beforeEach(() => {
12784
14057
  webex.internal.llm.setDatachannelToken = sinon.stub();
14058
+ webex.internal.llm.resolveSessionOwnership = sinon
14059
+ .stub()
14060
+ .returns({currentOwner: undefined, isOwner: true});
14061
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
12785
14062
  });
12786
14063
 
12787
14064
  it('saves datachannelToken into LLM as Default', () => {
@@ -12794,7 +14071,8 @@ describe('plugin-meetings', () => {
12794
14071
  assert.calledWithExactly(
12795
14072
  webex.internal.llm.setDatachannelToken,
12796
14073
  'default-token',
12797
- 'llm-default-session'
14074
+ 'llm-default-session',
14075
+ meeting.id
12798
14076
  );
12799
14077
  });
12800
14078
 
@@ -12808,7 +14086,8 @@ describe('plugin-meetings', () => {
12808
14086
  assert.calledWithExactly(
12809
14087
  webex.internal.llm.setDatachannelToken,
12810
14088
  'ps-token',
12811
- 'llm-practice-session'
14089
+ 'llm-practice-session',
14090
+ meeting.id
12812
14091
  );
12813
14092
  });
12814
14093
 
@@ -12826,12 +14105,14 @@ describe('plugin-meetings', () => {
12826
14105
  assert.calledWithExactly(
12827
14106
  webex.internal.llm.setDatachannelToken,
12828
14107
  'default-token',
12829
- 'llm-default-session'
14108
+ 'llm-default-session',
14109
+ meeting.id
12830
14110
  );
12831
14111
  assert.calledWithExactly(
12832
14112
  webex.internal.llm.setDatachannelToken,
12833
14113
  'ps-token',
12834
- 'llm-practice-session'
14114
+ 'llm-practice-session',
14115
+ meeting.id
12835
14116
  );
12836
14117
  });
12837
14118
 
@@ -12852,17 +14133,42 @@ describe('plugin-meetings', () => {
12852
14133
 
12853
14134
  assert.notCalled(webex.internal.llm.setDatachannelToken);
12854
14135
  });
14136
+
14137
+ it('writes token with meeting id as owner', () => {
14138
+ meeting.saveDataChannelToken({
14139
+ locus: {
14140
+ self: {datachannelToken: 'default-token'},
14141
+ },
14142
+ });
14143
+
14144
+ assert.calledOnceWithExactly(
14145
+ webex.internal.llm.setDatachannelToken,
14146
+ 'default-token',
14147
+ 'llm-default-session',
14148
+ meeting.id
14149
+ );
14150
+ });
12855
14151
  });
12856
14152
 
12857
14153
  describe('#clearDataChannelToken', () => {
12858
14154
  beforeEach(() => {
12859
- webex.internal.llm.resetDatachannelTokens = sinon.stub();
14155
+ webex.internal.llm.clearDatachannelToken = sinon.stub();
12860
14156
  });
12861
14157
 
12862
- it('calls resetDatachannelTokens on LLM', () => {
14158
+ it('delegates default and practice token clears to llm with meeting ownership id', () => {
12863
14159
  meeting.clearDataChannelToken();
12864
14160
 
12865
- assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
14161
+ assert.calledWithExactly(
14162
+ webex.internal.llm.clearDatachannelToken,
14163
+ 'llm-default-session',
14164
+ meeting.id
14165
+ );
14166
+ assert.calledWithExactly(
14167
+ webex.internal.llm.clearDatachannelToken,
14168
+ 'llm-practice-session',
14169
+ meeting.id
14170
+ );
14171
+ assert.callCount(webex.internal.llm.clearDatachannelToken, 2);
12866
14172
  });
12867
14173
  });
12868
14174
 
@@ -12872,6 +14178,7 @@ describe('plugin-meetings', () => {
12872
14178
  webex.internal.llm.getLocusUrl = sinon.stub();
12873
14179
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12874
14180
  webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
14181
+ webex.internal.llm.setRefreshHandler = sinon.stub();
12875
14182
  webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12876
14183
  webex.internal.llm.on = sinon.stub();
12877
14184
  webex.internal.llm.off = sinon.stub();
@@ -12889,7 +14196,7 @@ describe('plugin-meetings', () => {
12889
14196
  meeting.joinedWith = {state: 'any other state'};
12890
14197
  webex.internal.llm.getLocusUrl.returns('a url');
12891
14198
 
12892
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14199
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12893
14200
 
12894
14201
  const result = await meeting.updateLLMConnection();
12895
14202
 
@@ -12901,6 +14208,7 @@ describe('plugin-meetings', () => {
12901
14208
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12902
14209
  meeting.joinedWith = {state: 'JOINED'};
12903
14210
  meeting.locusInfo = {
14211
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12904
14212
  url: 'a url',
12905
14213
  info: {datachannelUrl: 'a datachannel url'},
12906
14214
  };
@@ -12937,7 +14245,7 @@ describe('plugin-meetings', () => {
12937
14245
  });
12938
14246
  it('connects if not already connected', async () => {
12939
14247
  meeting.joinedWith = {state: 'JOINED'};
12940
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14248
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12941
14249
 
12942
14250
  const result = await meeting.updateLLMConnection();
12943
14251
 
@@ -12948,7 +14256,14 @@ describe('plugin-meetings', () => {
12948
14256
  'a datachannel url',
12949
14257
  undefined
12950
14258
  );
14259
+ assert.calledOnceWithExactly(
14260
+ webex.internal.llm.setRefreshHandler,
14261
+ sinon.match.func,
14262
+ 'llm-default-session',
14263
+ meeting.id
14264
+ );
12951
14265
  assert.equal(result, 'something');
14266
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
12952
14267
  });
12953
14268
  it('disconnects if the locus url has changed', async () => {
12954
14269
  meeting.joinedWith = {state: 'JOINED'};
@@ -12957,6 +14272,7 @@ describe('plugin-meetings', () => {
12957
14272
  webex.internal.llm.getLocusUrl.returns('a url');
12958
14273
 
12959
14274
  meeting.locusInfo = {
14275
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12960
14276
  url: 'a different url',
12961
14277
  info: {datachannelUrl: 'a datachannel url'},
12962
14278
  self: {},
@@ -12967,7 +14283,7 @@ describe('plugin-meetings', () => {
12967
14283
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12968
14284
  code: 3050,
12969
14285
  reason: 'done (permanent)',
12970
- });
14286
+ }, 'llm-default-session', meeting.id);
12971
14287
 
12972
14288
  assert.calledWithExactly(
12973
14289
  webex.internal.llm.registerAndConnect,
@@ -13010,6 +14326,7 @@ describe('plugin-meetings', () => {
13010
14326
  webex.internal.llm.getLocusUrl.returns('a url');
13011
14327
 
13012
14328
  meeting.locusInfo = {
14329
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13013
14330
  url: 'a url',
13014
14331
  info: {datachannelUrl: 'a different datachannel url'},
13015
14332
  self: {},
@@ -13020,7 +14337,7 @@ describe('plugin-meetings', () => {
13020
14337
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
13021
14338
  code: 3050,
13022
14339
  reason: 'done (permanent)',
13023
- });
14340
+ }, 'llm-default-session', meeting.id);
13024
14341
 
13025
14342
  assert.calledWithExactly(
13026
14343
  webex.internal.llm.registerAndConnect,
@@ -13061,14 +14378,14 @@ describe('plugin-meetings', () => {
13061
14378
  webex.internal.llm.isConnected.returns(true);
13062
14379
  webex.internal.llm.getLocusUrl.returns('a url');
13063
14380
 
13064
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14381
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13065
14382
 
13066
14383
  const result = await meeting.updateLLMConnection();
13067
14384
 
13068
14385
  assert.calledWith(webex.internal.llm.disconnectLLM, {
13069
14386
  code: 3050,
13070
14387
  reason: 'done (permanent)',
13071
- });
14388
+ }, 'llm-default-session', meeting.id);
13072
14389
  assert.notCalled(webex.internal.llm.registerAndConnect);
13073
14390
  assert.equal(result, undefined);
13074
14391
  assert.isFalse(
@@ -13084,6 +14401,7 @@ describe('plugin-meetings', () => {
13084
14401
  webex.internal.llm.disconnectLLM.rejects(disconnectError);
13085
14402
 
13086
14403
  meeting.locusInfo = {
14404
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13087
14405
  url: 'a different url',
13088
14406
  info: {datachannelUrl: 'a datachannel url'},
13089
14407
  self: {},
@@ -13115,6 +14433,7 @@ describe('plugin-meetings', () => {
13115
14433
  it('still need connect main session data channel when PS started', async () => {
13116
14434
  meeting.joinedWith = {state: 'JOINED'};
13117
14435
  meeting.locusInfo = {
14436
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13118
14437
  url: 'a url',
13119
14438
  info: {
13120
14439
  datachannelUrl: 'a datachannel url',
@@ -13135,11 +14454,14 @@ describe('plugin-meetings', () => {
13135
14454
  it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13136
14455
  meeting.joinedWith = {state: 'JOINED'};
13137
14456
  meeting.locusInfo = {
14457
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13138
14458
  url: 'a url',
13139
14459
  info: {datachannelUrl: 'a datachannel url'},
13140
14460
  };
13141
14461
 
13142
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
14462
+ webex.internal.llm.getDatachannelToken
14463
+ .withArgs('llm-default-session', meeting.id)
14464
+ .returns('token-123');
13143
14465
 
13144
14466
  await meeting.updateLLMConnection();
13145
14467
 
@@ -13154,6 +14476,7 @@ describe('plugin-meetings', () => {
13154
14476
  it('passes undefined token when LLM has no token stored', async () => {
13155
14477
  meeting.joinedWith = {state: 'JOINED'};
13156
14478
  meeting.locusInfo = {
14479
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13157
14480
  url: 'a url',
13158
14481
  info: {datachannelUrl: 'a datachannel url'},
13159
14482
  };
@@ -13175,6 +14498,7 @@ describe('plugin-meetings', () => {
13175
14498
  it('does not pass token when data channel with jwt token is disabled', async () => {
13176
14499
  meeting.joinedWith = {state: 'JOINED'};
13177
14500
  meeting.locusInfo = {
14501
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13178
14502
  url: 'a url',
13179
14503
  info: {datachannelUrl: 'a datachannel url'},
13180
14504
  };
@@ -13193,6 +14517,197 @@ describe('plugin-meetings', () => {
13193
14517
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
14518
  });
13195
14519
 
14520
+ describe('ownership tag', () => {
14521
+ beforeEach(() => {
14522
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
14523
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
14524
+ // real LLM singleton behavior so the finally-block release in
14525
+ // cleanupLLMConneciton is reflected in subsequent reads.
14526
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
14527
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
14528
+ webex.internal.llm.getOwnerMeetingId.returns(id);
14529
+ });
14530
+ });
14531
+
14532
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
14533
+ meeting.joinedWith = {state: 'JOINED'};
14534
+ webex.internal.llm.isConnected.returns(true);
14535
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14536
+ // Locus/datachannel URL mismatch is the *normal* case when
14537
+ // another meeting owns the live socket -- each meeting has its
14538
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
14539
+ // because doing so would tear down the owning meeting's healthy
14540
+ // LLM socket and break its data channel.
14541
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
14542
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
14543
+ meeting.locusInfo = {
14544
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14545
+ url: 'a different url',
14546
+ info: {datachannelUrl: 'a different datachannel url'},
14547
+ self: {},
14548
+ };
14549
+
14550
+ const result = await meeting.updateLLMConnection();
14551
+
14552
+ assert.equal(result, undefined);
14553
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14554
+ assert.notCalled(webex.internal.llm.registerAndConnect);
14555
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14556
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
14557
+ });
14558
+
14559
+
14560
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
14561
+ meeting.joinedWith = {state: 'JOINED'};
14562
+ webex.internal.llm.isConnected.returns(true);
14563
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14564
+ webex.internal.llm.getLocusUrl.returns('a url');
14565
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14566
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
14567
+ meeting.locusInfo = {
14568
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14569
+ url: 'a different url',
14570
+ info: {datachannelUrl: 'a datachannel url'},
14571
+ self: {},
14572
+ };
14573
+
14574
+ try {
14575
+ await meeting.updateLLMConnection();
14576
+ } catch (e) {
14577
+ /* updateLLMConnection may reject when cleanup throws */
14578
+ }
14579
+
14580
+ // The owner-eligible finally branch must release the tag so a
14581
+ // subsequent reconnect attempt from any meeting is not blocked.
14582
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
14583
+ });
14584
+
14585
+ it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => {
14586
+ meeting.joinedWith = {state: 'JOINED'};
14587
+ webex.internal.llm.isConnected.returns(true);
14588
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14589
+ webex.internal.llm.getLocusUrl.returns('a url');
14590
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14591
+ webex.internal.llm.disconnectLLM.callsFake(async () => {
14592
+ webex.internal.llm.getOwnerMeetingId.returns('new-owner-id');
14593
+ throw new Error('disconnect failed');
14594
+ });
14595
+ meeting.locusInfo = {
14596
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14597
+ url: 'a different url',
14598
+ info: {datachannelUrl: 'a datachannel url'},
14599
+ self: {},
14600
+ };
14601
+
14602
+ try {
14603
+ await meeting.updateLLMConnection();
14604
+ } catch (e) {
14605
+ /* updateLLMConnection may reject when cleanup throws */
14606
+ }
14607
+
14608
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14609
+ assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id');
14610
+ });
14611
+
14612
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
14613
+ meeting.joinedWith = {state: 'JOINED'};
14614
+ webex.internal.llm.isConnected.returns(true);
14615
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14616
+ webex.internal.llm.getLocusUrl.returns('a url');
14617
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14618
+ meeting.locusInfo = {
14619
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14620
+ url: 'a different url',
14621
+ info: {datachannelUrl: 'a datachannel url'},
14622
+ self: {},
14623
+ };
14624
+
14625
+ await meeting.updateLLMConnection();
14626
+
14627
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14628
+ code: 3050,
14629
+ reason: 'done (permanent)',
14630
+ }, 'llm-default-session', meeting.id);
14631
+ assert.calledWithExactly(
14632
+ webex.internal.llm.registerAndConnect,
14633
+ 'a different url',
14634
+ 'a datachannel url',
14635
+ undefined
14636
+ );
14637
+ // setOwnerMeetingId is called twice: first with undefined in
14638
+ // cleanupLLMConneciton's finally block (so a failed disconnect
14639
+ // cannot leave a stale owner), then with this meeting's id
14640
+ // after registerAndConnect resolves.
14641
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
14642
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
14643
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
14644
+ });
14645
+
14646
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
14647
+ meeting.joinedWith = {state: 'JOINED'};
14648
+ webex.internal.llm.isConnected.returns(false);
14649
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14650
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14651
+
14652
+ await meeting.updateLLMConnection();
14653
+
14654
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14655
+ assert.calledOnceWithExactly(
14656
+ webex.internal.llm.setRefreshHandler,
14657
+ sinon.match.func,
14658
+ 'llm-default-session',
14659
+ meeting.id
14660
+ );
14661
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14662
+ });
14663
+
14664
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
14665
+ // Defensive path: if the LLM reports not-connected but an old
14666
+ // ownerId is still present (e.g. race before a successful
14667
+ // connections.delete), this meeting can still claim a fresh
14668
+ // connection.
14669
+ meeting.joinedWith = {state: 'JOINED'};
14670
+ webex.internal.llm.isConnected.returns(false);
14671
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
14672
+ webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined);
14673
+ webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token');
14674
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14675
+
14676
+ await meeting.updateLLMConnection();
14677
+
14678
+ assert.calledTwice(webex.internal.llm.getDatachannelToken);
14679
+ assert.calledWithExactly(
14680
+ webex.internal.llm.getDatachannelToken.firstCall,
14681
+ 'llm-default-session',
14682
+ meeting.id
14683
+ );
14684
+ assert.calledWithExactly(
14685
+ webex.internal.llm.getDatachannelToken.secondCall,
14686
+ 'llm-default-session'
14687
+ );
14688
+ assert.calledOnceWithExactly(
14689
+ webex.internal.llm.registerAndConnect,
14690
+ 'a url',
14691
+ 'a datachannel url',
14692
+ 'recovered-token'
14693
+ );
14694
+ assert.calledWithExactly(
14695
+ webex.internal.llm.setRefreshHandler.firstCall,
14696
+ sinon.match.func,
14697
+ 'llm-default-session',
14698
+ undefined
14699
+ );
14700
+ assert.calledTwice(webex.internal.llm.setRefreshHandler);
14701
+ assert.calledWithExactly(
14702
+ webex.internal.llm.setRefreshHandler.secondCall,
14703
+ sinon.match.func,
14704
+ 'llm-default-session',
14705
+ meeting.id
14706
+ );
14707
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14708
+ });
14709
+ });
14710
+
13196
14711
  describe('#clearMeetingData', () => {
13197
14712
  beforeEach(() => {
13198
14713
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13211,7 +14726,7 @@ describe('plugin-meetings', () => {
13211
14726
  assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13212
14727
  code: 3050,
13213
14728
  reason: 'done (permanent)',
13214
- });
14729
+ }, 'llm-default-session', meeting.id);
13215
14730
  assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13216
14731
  assert.calledWithExactly(
13217
14732
  webex.internal.llm.off,
@@ -13224,10 +14739,13 @@ describe('plugin-meetings', () => {
13224
14739
  meeting.processLocusLLMEvent
13225
14740
  );
13226
14741
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
- assert.calledOnce(meeting.stopTranscription);
13228
- assert.isUndefined(meeting.transcription);
13229
14742
  assert.calledOnce(meeting.clearDataChannelToken);
13230
- assert.calledOnce(meeting.annotation.deregisterEvents);
14743
+ // stopTranscription and annotation.deregisterEvents are not
14744
+ // called here: they run in stopListeningForMeetingEvents()
14745
+ // before /leave to avoid double-emitting
14746
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
14747
+ assert.notCalled(meeting.stopTranscription);
14748
+ assert.notCalled(meeting.annotation.deregisterEvents);
13231
14749
  });
13232
14750
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
14751
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13246,19 +14764,65 @@ describe('plugin-meetings', () => {
13246
14764
  meeting.processLocusLLMEvent
13247
14765
  );
13248
14766
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
- assert.calledOnce(meeting.stopTranscription);
13250
- assert.isUndefined(meeting.transcription);
13251
14767
  assert.calledOnce(meeting.clearDataChannelToken);
13252
- assert.calledOnce(meeting.annotation.deregisterEvents);
14768
+ assert.notCalled(meeting.stopTranscription);
14769
+ assert.notCalled(meeting.annotation.deregisterEvents);
13253
14770
  });
13254
- it('always calls stopTranscription even when transcription is undefined', async () => {
13255
- meeting.transcription = undefined;
13256
14771
 
13257
- await meeting.clearMeetingData();
14772
+ describe('ownership tag', () => {
14773
+ beforeEach(() => {
14774
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
14775
+ });
13258
14776
 
13259
- assert.calledOnce(meeting.stopTranscription);
13260
- assert.isUndefined(meeting.transcription);
13261
- assert.calledOnce(meeting.clearDataChannelToken);
14777
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
14778
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14779
+
14780
+ await meeting.clearMeetingData();
14781
+
14782
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14783
+ // clearDataChannelToken is always delegated; llm enforces
14784
+ // ownership and no-ops for non-owners internally.
14785
+ assert.calledOnce(meeting.clearDataChannelToken);
14786
+ // Listeners owned by *this* Meeting instance must still be
14787
+ // removed so a leaving subordinate meeting stops receiving
14788
+ // relay/locus events from the shared singleton.
14789
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14790
+ assert.calledWithExactly(
14791
+ webex.internal.llm.off,
14792
+ 'event:relay.event',
14793
+ meeting.processRelayEvent
14794
+ );
14795
+ assert.calledWithExactly(
14796
+ webex.internal.llm.off,
14797
+ 'event:locus.state_message',
14798
+ meeting.processLocusLLMEvent
14799
+ );
14800
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14801
+ });
14802
+
14803
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14804
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14805
+
14806
+ await meeting.clearMeetingData();
14807
+
14808
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14809
+ code: 3050,
14810
+ reason: 'done (permanent)',
14811
+ }, 'llm-default-session', meeting.id);
14812
+ assert.calledOnce(meeting.clearDataChannelToken);
14813
+ });
14814
+
14815
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14816
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14817
+
14818
+ await meeting.clearMeetingData();
14819
+
14820
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14821
+ code: 3050,
14822
+ reason: 'done (permanent)',
14823
+ }, 'llm-default-session', meeting.id);
14824
+ assert.calledOnce(meeting.clearDataChannelToken);
14825
+ });
13262
14826
  });
13263
14827
  });
13264
14828
  });
@@ -15012,16 +16576,25 @@ describe('plugin-meetings', () => {
15012
16576
  assert.notCalled(meeting.meetingRequest.sendReaction);
15013
16577
  });
15014
16578
 
15015
- it('should fail sending a reaction if reactionType is invalid ', async () => {
16579
+ it('should send a custom reaction type not in the known list', async () => {
15016
16580
  meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
15017
16581
 
15018
- await assert.isRejected(
15019
- meeting.sendReaction('invalid_reaction', 'light'),
15020
- Error,
15021
- 'invalid_reaction is not a valid reaction.'
15022
- );
16582
+ const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
15023
16583
 
15024
- assert.notCalled(meeting.meetingRequest.sendReaction);
16584
+ assert.exists(reactionPromise.then);
16585
+ await reactionPromise;
16586
+ assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
16587
+ reactionChannelUrl: 'Fake URL',
16588
+ reaction: {
16589
+ type: 'custom_reaction',
16590
+ tone: {
16591
+ type: 'light_skin_tone',
16592
+ codepoints: '1F3FB',
16593
+ shortcodes: ':skin-tone-2:',
16594
+ },
16595
+ },
16596
+ participantId: meeting.members.selfId,
16597
+ });
15025
16598
  });
15026
16599
 
15027
16600
  it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
@@ -16045,4 +17618,4 @@ describe('plugin-meetings', () => {
16045
17618
  assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
16046
17619
  });
16047
17620
  });
16048
- });
17621
+ });