@webex/plugin-meetings 3.12.0-next.8 → 3.12.0-next.80

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 (183) 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 +34 -8
  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 +1111 -724
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/muteState.js +10 -1
  52. package/dist/meeting/muteState.js.map +1 -1
  53. package/dist/meeting/request.js +5 -2
  54. package/dist/meeting/request.js.map +1 -1
  55. package/dist/meeting/util.js +29 -2
  56. package/dist/meeting/util.js.map +1 -1
  57. package/dist/meeting-info/meeting-info-v2.js +2 -2
  58. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  59. package/dist/meetings/index.js +231 -78
  60. package/dist/meetings/index.js.map +1 -1
  61. package/dist/meetings/meetings.types.js +6 -1
  62. package/dist/meetings/meetings.types.js.map +1 -1
  63. package/dist/meetings/request.js +39 -0
  64. package/dist/meetings/request.js.map +1 -1
  65. package/dist/meetings/util.js +79 -5
  66. package/dist/meetings/util.js.map +1 -1
  67. package/dist/member/index.js +10 -0
  68. package/dist/member/index.js.map +1 -1
  69. package/dist/member/types.js.map +1 -1
  70. package/dist/member/util.js +3 -0
  71. package/dist/member/util.js.map +1 -1
  72. package/dist/metrics/constants.js +4 -1
  73. package/dist/metrics/constants.js.map +1 -1
  74. package/dist/multistream/codec/constants.js +63 -0
  75. package/dist/multistream/codec/constants.js.map +1 -0
  76. package/dist/multistream/mediaRequestManager.js +62 -15
  77. package/dist/multistream/mediaRequestManager.js.map +1 -1
  78. package/dist/multistream/receiveSlot.js +9 -0
  79. package/dist/multistream/receiveSlot.js.map +1 -1
  80. package/dist/reactions/reactions.type.js.map +1 -1
  81. package/dist/recording-controller/index.js +1 -3
  82. package/dist/recording-controller/index.js.map +1 -1
  83. package/dist/types/config.d.ts +2 -0
  84. package/dist/types/constants.d.ts +12 -1
  85. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  86. package/dist/types/controls-options-manager/index.d.ts +16 -0
  87. package/dist/types/hashTree/constants.d.ts +2 -0
  88. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  89. package/dist/types/hashTree/utils.d.ts +18 -0
  90. package/dist/types/index.d.ts +3 -0
  91. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  92. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  93. package/dist/types/locus-info/index.d.ts +50 -6
  94. package/dist/types/locus-info/infoUtils.d.ts +8 -0
  95. package/dist/types/locus-info/types.d.ts +21 -1
  96. package/dist/types/media/properties.d.ts +1 -0
  97. package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
  98. package/dist/types/meeting/index.d.ts +80 -6
  99. package/dist/types/meeting/request.d.ts +1 -0
  100. package/dist/types/meeting/util.d.ts +9 -0
  101. package/dist/types/meetings/index.d.ts +30 -2
  102. package/dist/types/meetings/meetings.types.d.ts +15 -0
  103. package/dist/types/meetings/request.d.ts +14 -0
  104. package/dist/types/member/index.d.ts +1 -0
  105. package/dist/types/member/types.d.ts +1 -0
  106. package/dist/types/member/util.d.ts +1 -0
  107. package/dist/types/metrics/constants.d.ts +3 -0
  108. package/dist/types/multistream/codec/constants.d.ts +7 -0
  109. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  110. package/dist/types/reactions/reactions.type.d.ts +3 -0
  111. package/dist/webinar/index.js +305 -159
  112. package/dist/webinar/index.js.map +1 -1
  113. package/package.json +22 -22
  114. package/src/aiEnableRequest/index.ts +16 -0
  115. package/src/breakouts/breakout.ts +3 -1
  116. package/src/breakouts/index.ts +31 -0
  117. package/src/config.ts +2 -0
  118. package/src/constants.ts +16 -2
  119. package/src/controls-options-manager/constants.ts +14 -1
  120. package/src/controls-options-manager/index.ts +87 -28
  121. package/src/controls-options-manager/util.ts +81 -1
  122. package/src/hashTree/constants.ts +16 -0
  123. package/src/hashTree/hashTreeParser.ts +580 -196
  124. package/src/hashTree/utils.ts +36 -0
  125. package/src/index.ts +6 -0
  126. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  127. package/src/interceptors/locusRetry.ts +25 -4
  128. package/src/interpretation/index.ts +27 -9
  129. package/src/interpretation/interpretation.types.ts +11 -0
  130. package/src/locus-info/controlsUtils.ts +3 -1
  131. package/src/locus-info/index.ts +304 -100
  132. package/src/locus-info/infoUtils.ts +41 -6
  133. package/src/locus-info/types.ts +25 -1
  134. package/src/media/index.ts +3 -0
  135. package/src/media/properties.ts +1 -0
  136. package/src/meeting/in-meeting-actions.ts +8 -0
  137. package/src/meeting/index.ts +441 -65
  138. package/src/meeting/muteState.ts +10 -1
  139. package/src/meeting/request.ts +11 -0
  140. package/src/meeting/util.ts +33 -2
  141. package/src/meeting-info/meeting-info-v2.ts +4 -2
  142. package/src/meetings/index.ts +134 -44
  143. package/src/meetings/meetings.types.ts +19 -0
  144. package/src/meetings/request.ts +43 -0
  145. package/src/meetings/util.ts +97 -1
  146. package/src/member/index.ts +10 -0
  147. package/src/member/types.ts +1 -0
  148. package/src/member/util.ts +3 -0
  149. package/src/metrics/constants.ts +3 -0
  150. package/src/multistream/codec/constants.ts +58 -0
  151. package/src/multistream/mediaRequestManager.ts +119 -28
  152. package/src/multistream/receiveSlot.ts +18 -0
  153. package/src/reactions/reactions.type.ts +3 -0
  154. package/src/recording-controller/index.ts +1 -2
  155. package/src/webinar/index.ts +214 -36
  156. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  157. package/test/unit/spec/breakouts/breakout.ts +9 -3
  158. package/test/unit/spec/breakouts/index.ts +49 -0
  159. package/test/unit/spec/controls-options-manager/index.js +244 -29
  160. package/test/unit/spec/controls-options-manager/util.js +165 -0
  161. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  162. package/test/unit/spec/hashTree/utils.ts +125 -1
  163. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  164. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  165. package/test/unit/spec/interpretation/index.ts +26 -4
  166. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  167. package/test/unit/spec/locus-info/index.js +499 -81
  168. package/test/unit/spec/locus-info/infoUtils.js +87 -0
  169. package/test/unit/spec/media/index.ts +31 -0
  170. package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
  171. package/test/unit/spec/meeting/index.js +1552 -42
  172. package/test/unit/spec/meeting/muteState.js +81 -0
  173. package/test/unit/spec/meeting/request.js +12 -0
  174. package/test/unit/spec/meeting/utils.js +82 -0
  175. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  176. package/test/unit/spec/meetings/index.js +363 -10
  177. package/test/unit/spec/meetings/request.js +141 -0
  178. package/test/unit/spec/meetings/utils.js +189 -0
  179. package/test/unit/spec/member/index.js +7 -0
  180. package/test/unit/spec/member/util.js +24 -0
  181. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  182. package/test/unit/spec/recording-controller/index.js +9 -8
  183. 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
  {
@@ -792,7 +963,7 @@ describe('plugin-meetings', () => {
792
963
 
793
964
  // resets joinWithMediaRetryInfo
794
965
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
795
- isRetry: false,
966
+ retryCount: 0,
796
967
  prevJoinResponse: undefined,
797
968
  });
798
969
  });
@@ -864,7 +1035,12 @@ describe('plugin-meetings', () => {
864
1035
  meeting.join = sinon.stub().returns(Promise.reject(error));
865
1036
  meeting.locusUrl = null; // when join fails, we end up with null locusUrl
866
1037
 
867
- await assert.isRejected(meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}}));
1038
+ const thrownError = await assert.isRejected(
1039
+ meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}})
1040
+ );
1041
+
1042
+ // should throw the first attempt's error
1043
+ assert.equal(thrownError, error);
868
1044
 
869
1045
  assert.calledTwice(abortTurnDiscoveryStub);
870
1046
 
@@ -902,11 +1078,79 @@ describe('plugin-meetings', () => {
902
1078
 
903
1079
  // resets joinWithMediaRetryInfo
904
1080
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
905
- isRetry: false,
1081
+ retryCount: 0,
1082
+ prevJoinResponse: undefined,
1083
+ firstError: undefined,
1084
+ prevError: undefined,
1085
+ });
1086
+ });
1087
+
1088
+ it('should re-join on retry when join() fails on first attempt, and throw the first error if join fails again', async () => {
1089
+ const firstJoinError = new Error('first join error');
1090
+ const secondJoinError = new Error('second join error');
1091
+
1092
+ meeting.join = sinon
1093
+ .stub()
1094
+ .onFirstCall()
1095
+ .rejects(firstJoinError)
1096
+ .onSecondCall()
1097
+ .rejects(secondJoinError);
1098
+ meeting.locusUrl = null; // join never succeeds
1099
+
1100
+ const thrownError = await assert.isRejected(
1101
+ meeting.joinWithMedia({joinOptions, mediaOptions})
1102
+ );
1103
+
1104
+ // join() should be called twice — once for the first attempt, once for the re-join
1105
+ assert.calledTwice(meeting.join);
1106
+ // TURN discovery should be attempted twice (once per join)
1107
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1108
+
1109
+ // should throw the first attempt's error, not the second
1110
+ assert.equal(thrownError, firstJoinError);
1111
+
1112
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1113
+ assert.calledWith(
1114
+ Metrics.sendBehavioralMetric.firstCall,
1115
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1116
+ sinon.match({reason: firstJoinError.message, isRetry: false}),
1117
+ sinon.match.any
1118
+ );
1119
+ assert.calledWith(
1120
+ Metrics.sendBehavioralMetric.secondCall,
1121
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1122
+ sinon.match({reason: secondJoinError.message, isRetry: true}),
1123
+ sinon.match.any
1124
+ );
1125
+
1126
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
1127
+ retryCount: 0,
906
1128
  prevJoinResponse: undefined,
1129
+ firstError: undefined,
1130
+ prevError: undefined,
907
1131
  });
908
1132
  });
909
1133
 
1134
+ it('should NOT call join() again on retry when join() succeeded but addMediaInternal() failed', async () => {
1135
+ const addMediaError = new Error('addMedia error');
1136
+
1137
+ meeting.addMediaInternal = sinon
1138
+ .stub()
1139
+ .onFirstCall()
1140
+ .rejects(addMediaError)
1141
+ .onSecondCall()
1142
+ .resolves(test4);
1143
+
1144
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1145
+
1146
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1147
+
1148
+ // join() should only be called once — the successful join result is reused on retry
1149
+ assert.calledOnce(meeting.join);
1150
+ // TURN discovery request is only generated once (on the first attempt alongside join)
1151
+ assert.calledOnce(generateTurnDiscoveryRequestMessageStub);
1152
+ });
1153
+
910
1154
  it('should resolve if join() fails the first time but succeeds the second time', async () => {
911
1155
  const error = new Error('fake');
912
1156
  meeting.join = sinon
@@ -948,7 +1192,7 @@ describe('plugin-meetings', () => {
948
1192
 
949
1193
  // resets joinWithMediaRetryInfo
950
1194
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
951
- isRetry: false,
1195
+ retryCount: 0,
952
1196
  prevJoinResponse: undefined,
953
1197
  });
954
1198
  });
@@ -1017,6 +1261,127 @@ describe('plugin-meetings', () => {
1017
1261
  );
1018
1262
  });
1019
1263
 
1264
+ it('should throw the first attempt error when retry also fails with a different error', async () => {
1265
+ const firstError = new Error('first attempt error');
1266
+ const secondError = new Error('second attempt error');
1267
+
1268
+ const addMediaInternalResults = [];
1269
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1270
+ const defer = new Defer();
1271
+ addMediaInternalResults.push(defer);
1272
+ return defer.promise;
1273
+ });
1274
+
1275
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1276
+
1277
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1278
+
1279
+ await testUtils.flushPromises();
1280
+
1281
+ // 1st attempt fails
1282
+ addMediaInternalResults[0].reject(firstError);
1283
+ await testUtils.flushPromises();
1284
+
1285
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1286
+ assert.notCalled(leaveStub);
1287
+
1288
+ // 2nd (final) attempt fails
1289
+ addMediaInternalResults[1].reject(secondError);
1290
+ const thrownError = await assert.isRejected(result);
1291
+
1292
+ // should throw the first error, not the second
1293
+ assert.equal(thrownError, firstError);
1294
+
1295
+ // leave() should only be called after the last (2nd) attempt
1296
+ assert.calledOnce(leaveStub);
1297
+
1298
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1299
+ });
1300
+
1301
+ it('should throw the UserNotJoinedError as firstError when it occurs on the 1st attempt and 2nd attempt fails differently', async () => {
1302
+ const userNotJoinedError = new UserNotJoinedError();
1303
+ const secondError = new Error('second attempt error');
1304
+
1305
+ const addMediaInternalResults = [];
1306
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1307
+ const defer = new Defer();
1308
+ addMediaInternalResults.push(defer);
1309
+ return defer.promise;
1310
+ });
1311
+
1312
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1313
+
1314
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1315
+
1316
+ await testUtils.flushPromises();
1317
+
1318
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1319
+ addMediaInternalResults[0].reject(userNotJoinedError);
1320
+ await testUtils.flushPromises();
1321
+
1322
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1323
+ assert.notCalled(leaveStub);
1324
+
1325
+ // 2nd (final) attempt fails
1326
+ addMediaInternalResults[1].reject(secondError);
1327
+ const thrownError = await assert.isRejected(result);
1328
+
1329
+ // should throw the first (UserNotJoinedError), not the second
1330
+ assert.equal(thrownError, userNotJoinedError);
1331
+
1332
+ // join() called twice: original + re-join triggered by UserNotJoinedError in prevError
1333
+ assert.calledTwice(meeting.join);
1334
+ // leave() should only be called after the last (2nd) attempt
1335
+ assert.calledOnce(leaveStub);
1336
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1337
+ });
1338
+
1339
+ it('should throw the first UserNotJoinedError when it occurs on both 1st and 2nd attempts and a 3rd attempt also fails', async () => {
1340
+ const firstUserNotJoinedError = new UserNotJoinedError('first');
1341
+ const secondUserNotJoinedError = new UserNotJoinedError('second');
1342
+ const thirdError = new Error('third attempt error');
1343
+
1344
+ const addMediaInternalResults = [];
1345
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1346
+ const defer = new Defer();
1347
+ addMediaInternalResults.push(defer);
1348
+ return defer.promise;
1349
+ });
1350
+
1351
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1352
+
1353
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1354
+
1355
+ await testUtils.flushPromises();
1356
+
1357
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1358
+ addMediaInternalResults[0].reject(firstUserNotJoinedError);
1359
+ await testUtils.flushPromises();
1360
+
1361
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1362
+ assert.notCalled(leaveStub);
1363
+
1364
+ // 2nd attempt fails with UserNotJoinedError again — triggers another re-join
1365
+ addMediaInternalResults[1].reject(secondUserNotJoinedError);
1366
+ await testUtils.flushPromises();
1367
+
1368
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1369
+ assert.notCalled(leaveStub);
1370
+
1371
+ // 3rd (final) attempt fails
1372
+ addMediaInternalResults[2].reject(thirdError);
1373
+ const thrownError = await assert.isRejected(result);
1374
+
1375
+ // should throw the very first error across all 3 attempts
1376
+ assert.equal(thrownError, firstUserNotJoinedError);
1377
+
1378
+ // join() called 3 times: original + 2 re-joins triggered by prevError being UserNotJoinedError
1379
+ assert.calledThrice(meeting.join);
1380
+ // leave() should only be called after the last (3rd) attempt
1381
+ assert.calledOnce(leaveStub);
1382
+ assert.calledThrice(Metrics.sendBehavioralMetric);
1383
+ });
1384
+
1020
1385
  it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1021
1386
  const addMediaError = new Error('fake addMedia error');
1022
1387
  addMediaError.name = 'TypeError'; // This makes it a browser media error
@@ -1264,7 +1629,8 @@ describe('plugin-meetings', () => {
1264
1629
 
1265
1630
  await testUtils.flushPromises();
1266
1631
 
1267
- // check the callback works correctly on the 2nd attempt
1632
+ // check the callback works correctly on the 2nd attempt:
1633
+ // retryCount=1 with a non-UserNotJoinedError is terminal, so icePhase must be JOIN_MEETING_FINAL
1268
1634
  assert.equal(icePhaseCallbacks.length, 2);
1269
1635
  assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1270
1636
 
@@ -1274,6 +1640,118 @@ describe('plugin-meetings', () => {
1274
1640
  await assert.isRejected(result);
1275
1641
  });
1276
1642
 
1643
+ it('should allow an additional retry when UserNotJoinedError occurs and return JOIN_MEETING_FINAL on the 3rd attempt', async () => {
1644
+ const genericError = new Error('generic error');
1645
+ const userNotJoinedError = new UserNotJoinedError();
1646
+ const thirdError = new Error('third error');
1647
+
1648
+ const icePhaseCallbacks = [];
1649
+ const addMediaInternalResults = [];
1650
+
1651
+ meeting.addMediaInternal = sinon
1652
+ .stub()
1653
+ .callsFake((icePhaseCallback) => {
1654
+ const defer = new Defer();
1655
+
1656
+ icePhaseCallbacks.push(icePhaseCallback);
1657
+ addMediaInternalResults.push(defer);
1658
+ return defer.promise;
1659
+ });
1660
+
1661
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1662
+
1663
+ const result = meeting.joinWithMedia({
1664
+ joinOptions,
1665
+ mediaOptions,
1666
+ });
1667
+
1668
+ await testUtils.flushPromises();
1669
+
1670
+ // 1st attempt: retryCount=0 → JOIN_MEETING_RETRY
1671
+ assert.equal(icePhaseCallbacks.length, 1);
1672
+ assert.equal(icePhaseCallbacks[0](), 'JOIN_MEETING_RETRY');
1673
+ addMediaInternalResults[0].reject(genericError);
1674
+
1675
+ await testUtils.flushPromises();
1676
+
1677
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1678
+ assert.notCalled(leaveStub);
1679
+
1680
+ // 2nd attempt: retryCount=1 → JOIN_MEETING_FINAL
1681
+ // (In real usage this callback is never invoked when UserNotJoinedError is thrown,
1682
+ // because UserNotJoinedError is thrown before waitForMediaConnectionConnected() is reached.)
1683
+ assert.equal(icePhaseCallbacks.length, 2);
1684
+ assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1685
+ addMediaInternalResults[1].reject(userNotJoinedError);
1686
+
1687
+ await testUtils.flushPromises();
1688
+
1689
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1690
+ assert.notCalled(leaveStub);
1691
+
1692
+ // 3rd attempt: retryCount=2 → JOIN_MEETING_FINAL
1693
+ assert.equal(icePhaseCallbacks.length, 3);
1694
+ assert.equal(icePhaseCallbacks[2](), 'JOIN_MEETING_FINAL');
1695
+ addMediaInternalResults[2].reject(thirdError);
1696
+
1697
+ const thrownError = await assert.isRejected(result);
1698
+
1699
+ // should throw the first error
1700
+ assert.equal(thrownError, genericError);
1701
+
1702
+ // leave() should only be called once, after the last (3rd) attempt
1703
+ assert.calledOnce(leaveStub);
1704
+ });
1705
+
1706
+ it('should re-join when retrying after a UserNotJoinedError', async () => {
1707
+ const userNotJoinedError = new UserNotJoinedError();
1708
+
1709
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1710
+
1711
+ meeting.addMediaInternal = sinon
1712
+ .stub()
1713
+ .onFirstCall()
1714
+ .rejects(userNotJoinedError)
1715
+ .onSecondCall()
1716
+ .resolves(test4);
1717
+
1718
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1719
+
1720
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1721
+
1722
+ // join() should be called twice — once for the first attempt, once for the re-join after UserNotJoinedError
1723
+ assert.calledTwice(meeting.join);
1724
+ // TURN discovery should be attempted twice (once per join)
1725
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1726
+ // leave() should never be called when retrying after UserNotJoinedError
1727
+ assert.notCalled(leaveStub);
1728
+ });
1729
+
1730
+ it('should re-join when isUserInLeftState returns true on retry', async () => {
1731
+ const addMediaError = new Error('addMedia error');
1732
+
1733
+ sinon.stub(MeetingUtil, 'isUserInLeftState').returns(true);
1734
+
1735
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1736
+
1737
+ meeting.addMediaInternal = sinon
1738
+ .stub()
1739
+ .onFirstCall()
1740
+ .rejects(addMediaError)
1741
+ .onSecondCall()
1742
+ .resolves(test4);
1743
+
1744
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1745
+
1746
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1747
+
1748
+ // join() should be called twice — once for the first attempt, once because the user is in left state
1749
+ assert.calledTwice(meeting.join);
1750
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1751
+ // leave() should never be called when retrying after isUserInLeftState returns true
1752
+ assert.notCalled(leaveStub);
1753
+ });
1754
+
1277
1755
  [
1278
1756
  {
1279
1757
  errorName: 'SdpOfferCreationError',
@@ -1977,6 +2455,113 @@ describe('plugin-meetings', () => {
1977
2455
  fakeProcessedReaction
1978
2456
  );
1979
2457
  });
2458
+
2459
+ [
2460
+ {
2461
+ title: 'should skip a reaction when the default relay route does not match the LLM binding',
2462
+ isPracticeSessionConnected: false,
2463
+ route: 'wrong-default-route',
2464
+ defaultBinding: 'default-route',
2465
+ practiceBinding: 'practice-route',
2466
+ shouldProcess: false,
2467
+ expectedSessionLabel: 'default session',
2468
+ },
2469
+ {
2470
+ title: 'should process a reaction when the default relay route matches the LLM binding',
2471
+ isPracticeSessionConnected: false,
2472
+ route: 'default-route',
2473
+ defaultBinding: 'default-route',
2474
+ practiceBinding: 'practice-route',
2475
+ shouldProcess: true,
2476
+ },
2477
+ {
2478
+ title:
2479
+ 'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
2480
+ isPracticeSessionConnected: true,
2481
+ route: 'practice-route',
2482
+ defaultBinding: 'default-route',
2483
+ practiceBinding: 'practice-route',
2484
+ shouldProcess: true,
2485
+ },
2486
+ {
2487
+ title:
2488
+ 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
2489
+ isPracticeSessionConnected: true,
2490
+ route: 'default-route',
2491
+ defaultBinding: 'default-route',
2492
+ practiceBinding: 'practice-route',
2493
+ shouldProcess: false,
2494
+ expectedSessionLabel: 'practice session',
2495
+ },
2496
+ ].forEach(
2497
+ ({
2498
+ title,
2499
+ isPracticeSessionConnected,
2500
+ route,
2501
+ defaultBinding,
2502
+ practiceBinding,
2503
+ shouldProcess,
2504
+ expectedSessionLabel,
2505
+ }) => {
2506
+ it(title, () => {
2507
+ meeting.isReactionsSupported = sinon.stub().returns(true);
2508
+ meeting.config.receiveReactions = true;
2509
+ const fakeSendersName = 'Fake reactors name';
2510
+ meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
2511
+ webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
2512
+ return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
2513
+ });
2514
+ webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
2515
+ if (llmSessionId === LLM_PRACTICE_SESSION) {
2516
+ return practiceBinding;
2517
+ }
2518
+
2519
+ return defaultBinding;
2520
+ });
2521
+ const fakeReactionPayload = {
2522
+ type: 'fake_type',
2523
+ codepoints: 'fake_codepoints',
2524
+ shortcodes: 'fake_shortcodes',
2525
+ };
2526
+ const fakeSenderPayload = {
2527
+ participantId: 'fake_participant_id',
2528
+ };
2529
+ const fakeRelayEvent = {
2530
+ headers: {route},
2531
+ data: {
2532
+ relayType: REACTION_RELAY_TYPES.REACTION,
2533
+ reaction: fakeReactionPayload,
2534
+ sender: fakeSenderPayload,
2535
+ },
2536
+ };
2537
+ const fakeProcessedReaction = {
2538
+ reaction: fakeReactionPayload,
2539
+ sender: {
2540
+ id: fakeSenderPayload.participantId,
2541
+ name: fakeSendersName,
2542
+ },
2543
+ };
2544
+
2545
+ TriggerProxy.trigger.resetHistory();
2546
+ meeting.processRelayEvent(fakeRelayEvent);
2547
+
2548
+ if (shouldProcess) {
2549
+ assert.calledWith(
2550
+ TriggerProxy.trigger,
2551
+ sinon.match.instanceOf(Meeting),
2552
+ {
2553
+ file: 'meeting/index',
2554
+ function: 'join',
2555
+ },
2556
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
2557
+ fakeProcessedReaction
2558
+ );
2559
+ } else {
2560
+ assert.notCalled(TriggerProxy.trigger);
2561
+ }
2562
+ });
2563
+ }
2564
+ );
1980
2565
  });
1981
2566
 
1982
2567
  describe('#handleLLMOnline', () => {
@@ -2016,6 +2601,14 @@ describe('plugin-meetings', () => {
2016
2601
 
2017
2602
  assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2018
2603
  });
2604
+
2605
+ it('calls syncAllHashTreeDatasets on locusInfo', () => {
2606
+ sinon.stub(meeting.locusInfo, 'syncAllHashTreeDatasets').resolves();
2607
+
2608
+ meeting.handleLLMOnline();
2609
+
2610
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
2611
+ });
2019
2612
  });
2020
2613
 
2021
2614
  describe('#join', () => {
@@ -4534,6 +5127,297 @@ describe('plugin-meetings', () => {
4534
5127
  },
4535
5128
  });
4536
5129
  });
5130
+
5131
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
5132
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
5133
+ const fakeStats = new Map([
5134
+ [
5135
+ 'transport-1',
5136
+ {
5137
+ type: 'transport',
5138
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5139
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
5140
+ },
5141
+ ],
5142
+ [
5143
+ 'outbound-rtp-1',
5144
+ {
5145
+ type: 'outbound-rtp',
5146
+ ssrc: 12345,
5147
+ },
5148
+ ],
5149
+ ]);
5150
+
5151
+ statsAnalyzerStub.emit(
5152
+ {file: 'test', function: 'test'},
5153
+ StatsAnalyzerEventNames.STATS_UPDATE,
5154
+ {stats: fakeStats}
5155
+ );
5156
+
5157
+ assert.calledWith(
5158
+ TriggerProxy.trigger,
5159
+ sinon.match.instanceOf(Meeting),
5160
+ {
5161
+ file: 'meeting/index',
5162
+ function: 'setupStatsAnalyzerEventHandlers',
5163
+ },
5164
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5165
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5166
+ );
5167
+
5168
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5169
+ });
5170
+
5171
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
5172
+ const firstStats = new Map([
5173
+ [
5174
+ 'transport-1',
5175
+ {
5176
+ type: 'transport',
5177
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5178
+ },
5179
+ ],
5180
+ ]);
5181
+
5182
+ statsAnalyzerStub.emit(
5183
+ {file: 'test', function: 'test'},
5184
+ StatsAnalyzerEventNames.STATS_UPDATE,
5185
+ {stats: firstStats}
5186
+ );
5187
+
5188
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5189
+
5190
+ const secondStats = new Map([
5191
+ [
5192
+ 'transport-1',
5193
+ {
5194
+ type: 'transport',
5195
+ srtpCipher: 'AEAD_AES_256_GCM',
5196
+ },
5197
+ ],
5198
+ ]);
5199
+
5200
+ TriggerProxy.trigger.resetHistory();
5201
+
5202
+ statsAnalyzerStub.emit(
5203
+ {file: 'test', function: 'test'},
5204
+ StatsAnalyzerEventNames.STATS_UPDATE,
5205
+ {stats: secondStats}
5206
+ );
5207
+
5208
+ assert.calledWith(
5209
+ TriggerProxy.trigger,
5210
+ sinon.match.instanceOf(Meeting),
5211
+ {
5212
+ file: 'meeting/index',
5213
+ function: 'setupStatsAnalyzerEventHandlers',
5214
+ },
5215
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5216
+ {srtpCipher: 'AEAD_AES_256_GCM'}
5217
+ );
5218
+
5219
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
5220
+ });
5221
+
5222
+ it('does not emit event when srtpCipher has not changed', async () => {
5223
+ const firstStats = new Map([
5224
+ [
5225
+ 'transport-1',
5226
+ {
5227
+ type: 'transport',
5228
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5229
+ },
5230
+ ],
5231
+ ]);
5232
+
5233
+ statsAnalyzerStub.emit(
5234
+ {file: 'test', function: 'test'},
5235
+ StatsAnalyzerEventNames.STATS_UPDATE,
5236
+ {stats: firstStats}
5237
+ );
5238
+
5239
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5240
+
5241
+ TriggerProxy.trigger.resetHistory();
5242
+
5243
+ // Emit same cipher again
5244
+ statsAnalyzerStub.emit(
5245
+ {file: 'test', function: 'test'},
5246
+ StatsAnalyzerEventNames.STATS_UPDATE,
5247
+ {stats: firstStats}
5248
+ );
5249
+
5250
+ // Should not trigger event again
5251
+ assert.neverCalledWith(
5252
+ TriggerProxy.trigger,
5253
+ sinon.match.instanceOf(Meeting),
5254
+ sinon.match.any,
5255
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5256
+ sinon.match.any
5257
+ );
5258
+
5259
+ // Cipher should remain the same
5260
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5261
+ });
5262
+
5263
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
5264
+ const fakeStats = new Map([
5265
+ [
5266
+ 'outbound-rtp-1',
5267
+ {
5268
+ type: 'outbound-rtp',
5269
+ ssrc: 12345,
5270
+ },
5271
+ ],
5272
+ [
5273
+ 'inbound-rtp-1',
5274
+ {
5275
+ type: 'inbound-rtp',
5276
+ ssrc: 67890,
5277
+ },
5278
+ ],
5279
+ ]);
5280
+
5281
+ statsAnalyzerStub.emit(
5282
+ {file: 'test', function: 'test'},
5283
+ StatsAnalyzerEventNames.STATS_UPDATE,
5284
+ {stats: fakeStats}
5285
+ );
5286
+
5287
+ assert.neverCalledWith(
5288
+ TriggerProxy.trigger,
5289
+ sinon.match.instanceOf(Meeting),
5290
+ sinon.match.any,
5291
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5292
+ sinon.match.any
5293
+ );
5294
+
5295
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5296
+ });
5297
+
5298
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
5299
+ const fakeStats = new Map([
5300
+ [
5301
+ 'transport-1',
5302
+ {
5303
+ type: 'transport',
5304
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
5305
+ // no srtpCipher property
5306
+ },
5307
+ ],
5308
+ ]);
5309
+
5310
+ statsAnalyzerStub.emit(
5311
+ {file: 'test', function: 'test'},
5312
+ StatsAnalyzerEventNames.STATS_UPDATE,
5313
+ {stats: fakeStats}
5314
+ );
5315
+
5316
+ assert.neverCalledWith(
5317
+ TriggerProxy.trigger,
5318
+ sinon.match.instanceOf(Meeting),
5319
+ sinon.match.any,
5320
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5321
+ sinon.match.any
5322
+ );
5323
+
5324
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5325
+ });
5326
+
5327
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
5328
+ const fakeStats = new Map([
5329
+ [
5330
+ 'transport-1',
5331
+ {
5332
+ type: 'transport',
5333
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5334
+ },
5335
+ ],
5336
+ [
5337
+ 'transport-2',
5338
+ {
5339
+ type: 'transport',
5340
+ srtpCipher: 'AEAD_AES_256_GCM',
5341
+ },
5342
+ ],
5343
+ [
5344
+ 'outbound-rtp-1',
5345
+ {
5346
+ type: 'outbound-rtp',
5347
+ ssrc: 12345,
5348
+ },
5349
+ ],
5350
+ ]);
5351
+
5352
+ statsAnalyzerStub.emit(
5353
+ {file: 'test', function: 'test'},
5354
+ StatsAnalyzerEventNames.STATS_UPDATE,
5355
+ {stats: fakeStats}
5356
+ );
5357
+
5358
+ assert.calledWith(
5359
+ TriggerProxy.trigger,
5360
+ sinon.match.instanceOf(Meeting),
5361
+ {
5362
+ file: 'meeting/index',
5363
+ function: 'setupStatsAnalyzerEventHandlers',
5364
+ },
5365
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5366
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5367
+ );
5368
+
5369
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5370
+ });
5371
+
5372
+ it('handles empty stats map without errors', async () => {
5373
+ const emptyStats = new Map();
5374
+
5375
+ statsAnalyzerStub.emit(
5376
+ {file: 'test', function: 'test'},
5377
+ StatsAnalyzerEventNames.STATS_UPDATE,
5378
+ {stats: emptyStats}
5379
+ );
5380
+
5381
+ assert.neverCalledWith(
5382
+ TriggerProxy.trigger,
5383
+ sinon.match.instanceOf(Meeting),
5384
+ sinon.match.any,
5385
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5386
+ sinon.match.any
5387
+ );
5388
+
5389
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5390
+ });
5391
+
5392
+ it('logs cipher change when cipher is updated', async () => {
5393
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
5394
+
5395
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
5396
+
5397
+ const newStats = new Map([
5398
+ [
5399
+ 'transport-1',
5400
+ {
5401
+ type: 'transport',
5402
+ srtpCipher: 'AEAD_AES_256_GCM',
5403
+ },
5404
+ ],
5405
+ ]);
5406
+
5407
+ statsAnalyzerStub.emit(
5408
+ {file: 'test', function: 'test'},
5409
+ StatsAnalyzerEventNames.STATS_UPDATE,
5410
+ {stats: newStats}
5411
+ );
5412
+
5413
+ assert.calledWithMatch(
5414
+ loggerSpy,
5415
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
5416
+ );
5417
+
5418
+ loggerSpy.restore();
5419
+ });
5420
+ });
4537
5421
  });
4538
5422
 
4539
5423
  describe('handles StatsMonitor events', () => {
@@ -6429,6 +7313,9 @@ describe('plugin-meetings', () => {
6429
7313
 
6430
7314
  meeting.annotation.deregisterEvents = sinon.stub();
6431
7315
  webex.internal.llm.off = sinon.stub();
7316
+ webex.internal.mercury.off = sinon.stub();
7317
+ meeting.mercuryOnlineHandler = sinon.stub();
7318
+ meeting.mercuryOfflineHandler = sinon.stub();
6432
7319
 
6433
7320
  // A meeting needs to be joined to leave
6434
7321
  meeting.meetingState = 'ACTIVE';
@@ -6452,6 +7339,67 @@ describe('plugin-meetings', () => {
6452
7339
  assert.calledOnce(meeting.clearMeetingData);
6453
7340
  });
6454
7341
 
7342
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
7343
+ const onlineHandler = meeting.mercuryOnlineHandler;
7344
+ const offlineHandler = meeting.mercuryOfflineHandler;
7345
+
7346
+ await meeting.leave();
7347
+
7348
+ // All llm/mercury consumers (direct listeners, voicea transcription,
7349
+ // annotation) must be detached before the /leave request so that
7350
+ // in-flight events do not trigger unnecessary Locus syncs
7351
+ // (per Locus team recommendation).
7352
+ assert.callOrder(
7353
+ webex.internal.llm.off,
7354
+ webex.internal.mercury.off,
7355
+ meeting.stopTranscription,
7356
+ meeting.annotation.deregisterEvents,
7357
+ meeting.meetingRequest.leaveMeeting
7358
+ );
7359
+ assert.calledWithExactly(
7360
+ webex.internal.llm.off,
7361
+ 'event:relay.event',
7362
+ meeting.processRelayEvent
7363
+ );
7364
+ assert.calledWithExactly(
7365
+ webex.internal.llm.off,
7366
+ LOCUS_LLM_EVENT,
7367
+ meeting.processLocusLLMEvent
7368
+ );
7369
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7370
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7371
+ assert.isUndefined(meeting.mercuryOnlineHandler);
7372
+ assert.isUndefined(meeting.mercuryOfflineHandler);
7373
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7374
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7375
+ assert.isUndefined(meeting.transcription);
7376
+ });
7377
+
7378
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
7379
+ const onlineHandler = meeting.mercuryOnlineHandler;
7380
+ const offlineHandler = meeting.mercuryOfflineHandler;
7381
+ meeting.meetingRequest.leaveMeeting = sinon
7382
+ .stub()
7383
+ .returns(Promise.reject(new Error('leave failed')));
7384
+
7385
+ await meeting.leave().catch(() => {});
7386
+
7387
+ assert.calledWithExactly(
7388
+ webex.internal.llm.off,
7389
+ 'event:relay.event',
7390
+ meeting.processRelayEvent
7391
+ );
7392
+ assert.calledWithExactly(
7393
+ webex.internal.llm.off,
7394
+ LOCUS_LLM_EVENT,
7395
+ meeting.processLocusLLMEvent
7396
+ );
7397
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7398
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7399
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7400
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7401
+ });
7402
+
6455
7403
  it('should reset call diagnostic latencies correctly', async () => {
6456
7404
  const leave = meeting.leave();
6457
7405
 
@@ -8459,6 +9407,9 @@ describe('plugin-meetings', () => {
8459
9407
 
8460
9408
  meeting.annotation.deregisterEvents = sinon.stub();
8461
9409
  webex.internal.llm.off = sinon.stub();
9410
+ webex.internal.mercury.off = sinon.stub();
9411
+ meeting.mercuryOnlineHandler = sinon.stub();
9412
+ meeting.mercuryOfflineHandler = sinon.stub();
8462
9413
 
8463
9414
  // A meeting needs to be joined to end
8464
9415
  meeting.meetingState = 'ACTIVE';
@@ -8481,6 +9432,66 @@ describe('plugin-meetings', () => {
8481
9432
  assert.calledOnce(meeting?.unsetPeerConnections);
8482
9433
  assert.calledOnce(meeting?.clearMeetingData);
8483
9434
  });
9435
+
9436
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
9437
+ const onlineHandler = meeting.mercuryOnlineHandler;
9438
+ const offlineHandler = meeting.mercuryOfflineHandler;
9439
+
9440
+ await meeting.endMeetingForAll();
9441
+
9442
+ // All llm/mercury consumers (direct listeners, voicea transcription,
9443
+ // annotation) must be detached before the /end request so that
9444
+ // in-flight events do not trigger unnecessary Locus syncs
9445
+ // (per Locus team recommendation).
9446
+ assert.callOrder(
9447
+ webex.internal.llm.off,
9448
+ webex.internal.mercury.off,
9449
+ meeting.stopTranscription,
9450
+ meeting.annotation.deregisterEvents,
9451
+ meeting.meetingRequest.endMeetingForAll
9452
+ );
9453
+ assert.calledWithExactly(
9454
+ webex.internal.llm.off,
9455
+ 'event:relay.event',
9456
+ meeting.processRelayEvent
9457
+ );
9458
+ assert.calledWithExactly(
9459
+ webex.internal.llm.off,
9460
+ LOCUS_LLM_EVENT,
9461
+ meeting.processLocusLLMEvent
9462
+ );
9463
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9464
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9465
+ assert.isUndefined(meeting.mercuryOnlineHandler);
9466
+ assert.isUndefined(meeting.mercuryOfflineHandler);
9467
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9468
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9469
+ });
9470
+
9471
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
9472
+ const onlineHandler = meeting.mercuryOnlineHandler;
9473
+ const offlineHandler = meeting.mercuryOfflineHandler;
9474
+ meeting.meetingRequest.endMeetingForAll = sinon
9475
+ .stub()
9476
+ .returns(Promise.reject(new Error('end failed')));
9477
+
9478
+ await meeting.endMeetingForAll().catch(() => {});
9479
+
9480
+ assert.calledWithExactly(
9481
+ webex.internal.llm.off,
9482
+ 'event:relay.event',
9483
+ meeting.processRelayEvent
9484
+ );
9485
+ assert.calledWithExactly(
9486
+ webex.internal.llm.off,
9487
+ LOCUS_LLM_EVENT,
9488
+ meeting.processLocusLLMEvent
9489
+ );
9490
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9491
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9492
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9493
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9494
+ });
8484
9495
  });
8485
9496
 
8486
9497
  describe('#moveTo', () => {
@@ -10417,7 +11428,7 @@ describe('plugin-meetings', () => {
10417
11428
  );
10418
11429
  done();
10419
11430
  });
10420
- it('listens to the self admitted guest event without blocking on token prefetch', async () => {
11431
+ it('listens to the self admitted guest event and waits for token prefetch before reconnecting LLM', async () => {
10421
11432
  meeting.stopKeepAlive = sinon.stub();
10422
11433
  meeting.updateLLMConnection = sinon.stub();
10423
11434
  let resolvePrefetch;
@@ -10443,7 +11454,7 @@ describe('plugin-meetings', () => {
10443
11454
  'meeting:self:guestAdmitted',
10444
11455
  {payload: test1}
10445
11456
  );
10446
- assert.calledOnce(meeting.updateLLMConnection);
11457
+ assert.notCalled(meeting.updateLLMConnection);
10447
11458
  assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
10448
11459
 
10449
11460
  assert.calledOnceWithExactly(
@@ -10456,6 +11467,7 @@ describe('plugin-meetings', () => {
10456
11467
 
10457
11468
  resolvePrefetch(false);
10458
11469
  await Promise.resolve();
11470
+ await Promise.resolve();
10459
11471
 
10460
11472
  assert.calledOnce(meeting.updateLLMConnection);
10461
11473
  });
@@ -10971,6 +11983,92 @@ describe('plugin-meetings', () => {
10971
11983
  );
10972
11984
  });
10973
11985
 
11986
+ const recordingTestCases = [
11987
+ {
11988
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
11989
+ state: RECORDING_STATE.RECORDING,
11990
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11991
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11992
+ },
11993
+ {
11994
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
11995
+ state: RECORDING_STATE.IDLE,
11996
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
11997
+ expectedRecordingState: RECORDING_STATE.IDLE,
11998
+ },
11999
+ {
12000
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
12001
+ state: RECORDING_STATE.PAUSED,
12002
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
12003
+ expectedRecordingState: RECORDING_STATE.PAUSED,
12004
+ },
12005
+ {
12006
+ description:
12007
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
12008
+ state: RECORDING_STATE.RESUMED,
12009
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
12010
+ expectedRecordingState: RECORDING_STATE.RECORDING,
12011
+ },
12012
+ ];
12013
+
12014
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
12015
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
12016
+ const modifiedBy = 'user-id-123';
12017
+ const lastModified = '2026-01-01T00:00:00Z';
12018
+
12019
+ await meeting.locusInfo.emitScoped(
12020
+ {function: 'test', file: 'test'},
12021
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
12022
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
12023
+ );
12024
+
12025
+ assert.deepEqual(meeting.recording, {
12026
+ state: expectedRecordingState,
12027
+ modifiedBy,
12028
+ lastModified,
12029
+ modifiedByServiceAppName: undefined,
12030
+ modifiedByServiceAppId: undefined,
12031
+ });
12032
+
12033
+ assert.calledWith(
12034
+ TriggerProxy.trigger,
12035
+ meeting,
12036
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
12037
+ expectedEvent,
12038
+ meeting.recording
12039
+ );
12040
+ });
12041
+ });
12042
+
12043
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
12044
+ const modifiedBy = 'user-id-123';
12045
+ const lastModified = '2026-01-01T00:00:00Z';
12046
+ const modifiedByServiceAppName = 'My Bot';
12047
+ const modifiedByServiceAppId = 'app-id-123';
12048
+
12049
+ await meeting.locusInfo.emitScoped(
12050
+ {function: 'test', file: 'test'},
12051
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
12052
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
12053
+ );
12054
+
12055
+ assert.deepEqual(meeting.recording, {
12056
+ state: RECORDING_STATE.RECORDING,
12057
+ modifiedBy,
12058
+ lastModified,
12059
+ modifiedByServiceAppName,
12060
+ modifiedByServiceAppId,
12061
+ });
12062
+
12063
+ assert.calledWith(
12064
+ TriggerProxy.trigger,
12065
+ meeting,
12066
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
12067
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
12068
+ meeting.recording
12069
+ );
12070
+ });
12071
+
10974
12072
  it('listens to the locus interpretation update event', () => {
10975
12073
  const interpretation = {
10976
12074
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11024,6 +12122,7 @@ describe('plugin-meetings', () => {
11024
12122
  meeting.annotation.locusUrlUpdate = sinon.stub();
11025
12123
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11026
12124
  meeting.webinar.locusUrlUpdate = sinon.stub();
12125
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11027
12126
 
11028
12127
  meeting.locusInfo.emit(
11029
12128
  {function: 'test', file: 'test'},
@@ -11038,6 +12137,7 @@ describe('plugin-meetings', () => {
11038
12137
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11039
12138
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11040
12139
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
12140
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11041
12141
  assert.equal(meeting.locusUrl, newLocusUrl);
11042
12142
  assert(meeting.locusId, '12345');
11043
12143
 
@@ -11353,6 +12453,109 @@ describe('plugin-meetings', () => {
11353
12453
  });
11354
12454
  });
11355
12455
 
12456
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
12457
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
12458
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12459
+
12460
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
12461
+ meeting.destination = {info: {topic: 'old'}};
12462
+
12463
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12464
+
12465
+ assert.equal(meeting.destination, syncedLocus);
12466
+ });
12467
+
12468
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
12469
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12470
+ const originalDestination = {destination: 'original-destination'};
12471
+
12472
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
12473
+ meeting.destination = originalDestination;
12474
+
12475
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12476
+
12477
+ assert.equal(meeting.destination, originalDestination);
12478
+ });
12479
+
12480
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
12481
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12482
+
12483
+ meeting.meetingInfo = {};
12484
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12485
+
12486
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12487
+
12488
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
12489
+ });
12490
+
12491
+ it('does not fetch meeting info when destination has no info', () => {
12492
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12493
+
12494
+ meeting.meetingInfo = {};
12495
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
12496
+
12497
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12498
+
12499
+ assert.notCalled(fetchMeetingInfoStub);
12500
+ });
12501
+
12502
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
12503
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12504
+
12505
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
12506
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12507
+
12508
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12509
+
12510
+ assert.notCalled(fetchMeetingInfoStub);
12511
+ });
12512
+
12513
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
12514
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12515
+
12516
+ meeting.meetingInfo = {};
12517
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12518
+ meeting.fetchMeetingInfoTimeoutId = 42;
12519
+
12520
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12521
+
12522
+ assert.notCalled(fetchMeetingInfoStub);
12523
+ });
12524
+
12525
+ ['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
12526
+ it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
12527
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12528
+
12529
+ meeting.meetingInfo = {};
12530
+ meeting.destination = {
12531
+ url: 'https://locus.example.com/locus/123',
12532
+ info: {topic: 'x'},
12533
+ };
12534
+
12535
+ meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
12536
+
12537
+ assert.notCalled(fetchMeetingInfoStub);
12538
+ });
12539
+ });
12540
+
12541
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
12542
+ const error = new Error('fetch failed');
12543
+
12544
+ meeting.meetingInfo = {};
12545
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12546
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
12547
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
12548
+
12549
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
12550
+
12551
+ assert.calledOnce(loggerInfoStub);
12552
+ assert.match(
12553
+ loggerInfoStub.firstCall.args[0],
12554
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
12555
+ );
12556
+ });
12557
+ });
12558
+
11356
12559
  describe('#emailInput', () => {
11357
12560
  it('should set the email input', () => {
11358
12561
  assert.notOk(meeting.emailInput);
@@ -11955,6 +13158,7 @@ describe('plugin-meetings', () => {
11955
13158
  let showAutoEndMeetingWarningSpy;
11956
13159
  let canAttendeeRequestAiAssistantEnabledSpy;
11957
13160
  let attendeeRequestAiAssistantDeclinedAllSpy;
13161
+ let isAnonymizeDisplayNamesEnabledSpy;
11958
13162
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11959
13163
 
11960
13164
  beforeEach(() => {
@@ -12003,6 +13207,10 @@ describe('plugin-meetings', () => {
12003
13207
  MeetingUtil,
12004
13208
  'attendeeRequestAiAssistantDeclinedAll'
12005
13209
  );
13210
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
13211
+ MeetingUtil,
13212
+ 'isAnonymizeDisplayNamesEnabled'
13213
+ );
12006
13214
  });
12007
13215
 
12008
13216
  afterEach(() => {
@@ -12011,6 +13219,7 @@ describe('plugin-meetings', () => {
12011
13219
  showAutoEndMeetingWarningSpy.restore();
12012
13220
  canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
13221
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
13222
+ isAnonymizeDisplayNamesEnabledSpy.restore();
12014
13223
  });
12015
13224
 
12016
13225
  forEach(
@@ -12568,6 +13777,7 @@ describe('plugin-meetings', () => {
12568
13777
  meeting.roles
12569
13778
  );
12570
13779
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13780
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12571
13781
 
12572
13782
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12573
13783
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12782,6 +13992,10 @@ describe('plugin-meetings', () => {
12782
13992
  describe('#saveDataChannelToken', () => {
12783
13993
  beforeEach(() => {
12784
13994
  webex.internal.llm.setDatachannelToken = sinon.stub();
13995
+ webex.internal.llm.resolveSessionOwnership = sinon
13996
+ .stub()
13997
+ .returns({currentOwner: undefined, isOwner: true});
13998
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
12785
13999
  });
12786
14000
 
12787
14001
  it('saves datachannelToken into LLM as Default', () => {
@@ -12794,7 +14008,8 @@ describe('plugin-meetings', () => {
12794
14008
  assert.calledWithExactly(
12795
14009
  webex.internal.llm.setDatachannelToken,
12796
14010
  'default-token',
12797
- 'llm-default-session'
14011
+ 'llm-default-session',
14012
+ meeting.id
12798
14013
  );
12799
14014
  });
12800
14015
 
@@ -12808,7 +14023,8 @@ describe('plugin-meetings', () => {
12808
14023
  assert.calledWithExactly(
12809
14024
  webex.internal.llm.setDatachannelToken,
12810
14025
  'ps-token',
12811
- 'llm-practice-session'
14026
+ 'llm-practice-session',
14027
+ meeting.id
12812
14028
  );
12813
14029
  });
12814
14030
 
@@ -12826,12 +14042,14 @@ describe('plugin-meetings', () => {
12826
14042
  assert.calledWithExactly(
12827
14043
  webex.internal.llm.setDatachannelToken,
12828
14044
  'default-token',
12829
- 'llm-default-session'
14045
+ 'llm-default-session',
14046
+ meeting.id
12830
14047
  );
12831
14048
  assert.calledWithExactly(
12832
14049
  webex.internal.llm.setDatachannelToken,
12833
14050
  'ps-token',
12834
- 'llm-practice-session'
14051
+ 'llm-practice-session',
14052
+ meeting.id
12835
14053
  );
12836
14054
  });
12837
14055
 
@@ -12852,17 +14070,42 @@ describe('plugin-meetings', () => {
12852
14070
 
12853
14071
  assert.notCalled(webex.internal.llm.setDatachannelToken);
12854
14072
  });
14073
+
14074
+ it('writes token with meeting id as owner', () => {
14075
+ meeting.saveDataChannelToken({
14076
+ locus: {
14077
+ self: {datachannelToken: 'default-token'},
14078
+ },
14079
+ });
14080
+
14081
+ assert.calledOnceWithExactly(
14082
+ webex.internal.llm.setDatachannelToken,
14083
+ 'default-token',
14084
+ 'llm-default-session',
14085
+ meeting.id
14086
+ );
14087
+ });
12855
14088
  });
12856
14089
 
12857
14090
  describe('#clearDataChannelToken', () => {
12858
14091
  beforeEach(() => {
12859
- webex.internal.llm.resetDatachannelTokens = sinon.stub();
14092
+ webex.internal.llm.clearDatachannelToken = sinon.stub();
12860
14093
  });
12861
14094
 
12862
- it('calls resetDatachannelTokens on LLM', () => {
14095
+ it('delegates default and practice token clears to llm with meeting ownership id', () => {
12863
14096
  meeting.clearDataChannelToken();
12864
14097
 
12865
- assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
14098
+ assert.calledWithExactly(
14099
+ webex.internal.llm.clearDatachannelToken,
14100
+ 'llm-default-session',
14101
+ meeting.id
14102
+ );
14103
+ assert.calledWithExactly(
14104
+ webex.internal.llm.clearDatachannelToken,
14105
+ 'llm-practice-session',
14106
+ meeting.id
14107
+ );
14108
+ assert.callCount(webex.internal.llm.clearDatachannelToken, 2);
12866
14109
  });
12867
14110
  });
12868
14111
 
@@ -12872,6 +14115,7 @@ describe('plugin-meetings', () => {
12872
14115
  webex.internal.llm.getLocusUrl = sinon.stub();
12873
14116
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12874
14117
  webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
14118
+ webex.internal.llm.setRefreshHandler = sinon.stub();
12875
14119
  webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12876
14120
  webex.internal.llm.on = sinon.stub();
12877
14121
  webex.internal.llm.off = sinon.stub();
@@ -12889,7 +14133,7 @@ describe('plugin-meetings', () => {
12889
14133
  meeting.joinedWith = {state: 'any other state'};
12890
14134
  webex.internal.llm.getLocusUrl.returns('a url');
12891
14135
 
12892
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14136
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12893
14137
 
12894
14138
  const result = await meeting.updateLLMConnection();
12895
14139
 
@@ -12901,6 +14145,7 @@ describe('plugin-meetings', () => {
12901
14145
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12902
14146
  meeting.joinedWith = {state: 'JOINED'};
12903
14147
  meeting.locusInfo = {
14148
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12904
14149
  url: 'a url',
12905
14150
  info: {datachannelUrl: 'a datachannel url'},
12906
14151
  };
@@ -12937,7 +14182,7 @@ describe('plugin-meetings', () => {
12937
14182
  });
12938
14183
  it('connects if not already connected', async () => {
12939
14184
  meeting.joinedWith = {state: 'JOINED'};
12940
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14185
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12941
14186
 
12942
14187
  const result = await meeting.updateLLMConnection();
12943
14188
 
@@ -12948,7 +14193,14 @@ describe('plugin-meetings', () => {
12948
14193
  'a datachannel url',
12949
14194
  undefined
12950
14195
  );
14196
+ assert.calledOnceWithExactly(
14197
+ webex.internal.llm.setRefreshHandler,
14198
+ sinon.match.func,
14199
+ 'llm-default-session',
14200
+ meeting.id
14201
+ );
12951
14202
  assert.equal(result, 'something');
14203
+ assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true});
12952
14204
  });
12953
14205
  it('disconnects if the locus url has changed', async () => {
12954
14206
  meeting.joinedWith = {state: 'JOINED'};
@@ -12957,6 +14209,7 @@ describe('plugin-meetings', () => {
12957
14209
  webex.internal.llm.getLocusUrl.returns('a url');
12958
14210
 
12959
14211
  meeting.locusInfo = {
14212
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
12960
14213
  url: 'a different url',
12961
14214
  info: {datachannelUrl: 'a datachannel url'},
12962
14215
  self: {},
@@ -12967,7 +14220,7 @@ describe('plugin-meetings', () => {
12967
14220
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12968
14221
  code: 3050,
12969
14222
  reason: 'done (permanent)',
12970
- });
14223
+ }, 'llm-default-session', meeting.id);
12971
14224
 
12972
14225
  assert.calledWithExactly(
12973
14226
  webex.internal.llm.registerAndConnect,
@@ -13010,6 +14263,7 @@ describe('plugin-meetings', () => {
13010
14263
  webex.internal.llm.getLocusUrl.returns('a url');
13011
14264
 
13012
14265
  meeting.locusInfo = {
14266
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13013
14267
  url: 'a url',
13014
14268
  info: {datachannelUrl: 'a different datachannel url'},
13015
14269
  self: {},
@@ -13020,7 +14274,7 @@ describe('plugin-meetings', () => {
13020
14274
  assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
13021
14275
  code: 3050,
13022
14276
  reason: 'done (permanent)',
13023
- });
14277
+ }, 'llm-default-session', meeting.id);
13024
14278
 
13025
14279
  assert.calledWithExactly(
13026
14280
  webex.internal.llm.registerAndConnect,
@@ -13061,14 +14315,14 @@ describe('plugin-meetings', () => {
13061
14315
  webex.internal.llm.isConnected.returns(true);
13062
14316
  webex.internal.llm.getLocusUrl.returns('a url');
13063
14317
 
13064
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14318
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13065
14319
 
13066
14320
  const result = await meeting.updateLLMConnection();
13067
14321
 
13068
14322
  assert.calledWith(webex.internal.llm.disconnectLLM, {
13069
14323
  code: 3050,
13070
14324
  reason: 'done (permanent)',
13071
- });
14325
+ }, 'llm-default-session', meeting.id);
13072
14326
  assert.notCalled(webex.internal.llm.registerAndConnect);
13073
14327
  assert.equal(result, undefined);
13074
14328
  assert.isFalse(
@@ -13084,6 +14338,7 @@ describe('plugin-meetings', () => {
13084
14338
  webex.internal.llm.disconnectLLM.rejects(disconnectError);
13085
14339
 
13086
14340
  meeting.locusInfo = {
14341
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13087
14342
  url: 'a different url',
13088
14343
  info: {datachannelUrl: 'a datachannel url'},
13089
14344
  self: {},
@@ -13115,6 +14370,7 @@ describe('plugin-meetings', () => {
13115
14370
  it('still need connect main session data channel when PS started', async () => {
13116
14371
  meeting.joinedWith = {state: 'JOINED'};
13117
14372
  meeting.locusInfo = {
14373
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13118
14374
  url: 'a url',
13119
14375
  info: {
13120
14376
  datachannelUrl: 'a datachannel url',
@@ -13135,11 +14391,14 @@ describe('plugin-meetings', () => {
13135
14391
  it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13136
14392
  meeting.joinedWith = {state: 'JOINED'};
13137
14393
  meeting.locusInfo = {
14394
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13138
14395
  url: 'a url',
13139
14396
  info: {datachannelUrl: 'a datachannel url'},
13140
14397
  };
13141
14398
 
13142
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
14399
+ webex.internal.llm.getDatachannelToken
14400
+ .withArgs('llm-default-session', meeting.id)
14401
+ .returns('token-123');
13143
14402
 
13144
14403
  await meeting.updateLLMConnection();
13145
14404
 
@@ -13154,6 +14413,7 @@ describe('plugin-meetings', () => {
13154
14413
  it('passes undefined token when LLM has no token stored', async () => {
13155
14414
  meeting.joinedWith = {state: 'JOINED'};
13156
14415
  meeting.locusInfo = {
14416
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13157
14417
  url: 'a url',
13158
14418
  info: {datachannelUrl: 'a datachannel url'},
13159
14419
  };
@@ -13175,6 +14435,7 @@ describe('plugin-meetings', () => {
13175
14435
  it('does not pass token when data channel with jwt token is disabled', async () => {
13176
14436
  meeting.joinedWith = {state: 'JOINED'};
13177
14437
  meeting.locusInfo = {
14438
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
13178
14439
  url: 'a url',
13179
14440
  info: {datachannelUrl: 'a datachannel url'},
13180
14441
  };
@@ -13193,6 +14454,197 @@ describe('plugin-meetings', () => {
13193
14454
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
14455
  });
13195
14456
 
14457
+ describe('ownership tag', () => {
14458
+ beforeEach(() => {
14459
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
14460
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
14461
+ // real LLM singleton behavior so the finally-block release in
14462
+ // cleanupLLMConneciton is reflected in subsequent reads.
14463
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
14464
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
14465
+ webex.internal.llm.getOwnerMeetingId.returns(id);
14466
+ });
14467
+ });
14468
+
14469
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
14470
+ meeting.joinedWith = {state: 'JOINED'};
14471
+ webex.internal.llm.isConnected.returns(true);
14472
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14473
+ // Locus/datachannel URL mismatch is the *normal* case when
14474
+ // another meeting owns the live socket -- each meeting has its
14475
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
14476
+ // because doing so would tear down the owning meeting's healthy
14477
+ // LLM socket and break its data channel.
14478
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
14479
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
14480
+ meeting.locusInfo = {
14481
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14482
+ url: 'a different url',
14483
+ info: {datachannelUrl: 'a different datachannel url'},
14484
+ self: {},
14485
+ };
14486
+
14487
+ const result = await meeting.updateLLMConnection();
14488
+
14489
+ assert.equal(result, undefined);
14490
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14491
+ assert.notCalled(webex.internal.llm.registerAndConnect);
14492
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14493
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
14494
+ });
14495
+
14496
+
14497
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
14498
+ meeting.joinedWith = {state: 'JOINED'};
14499
+ webex.internal.llm.isConnected.returns(true);
14500
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14501
+ webex.internal.llm.getLocusUrl.returns('a url');
14502
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14503
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
14504
+ meeting.locusInfo = {
14505
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14506
+ url: 'a different url',
14507
+ info: {datachannelUrl: 'a datachannel url'},
14508
+ self: {},
14509
+ };
14510
+
14511
+ try {
14512
+ await meeting.updateLLMConnection();
14513
+ } catch (e) {
14514
+ /* updateLLMConnection may reject when cleanup throws */
14515
+ }
14516
+
14517
+ // The owner-eligible finally branch must release the tag so a
14518
+ // subsequent reconnect attempt from any meeting is not blocked.
14519
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
14520
+ });
14521
+
14522
+ it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => {
14523
+ meeting.joinedWith = {state: 'JOINED'};
14524
+ webex.internal.llm.isConnected.returns(true);
14525
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14526
+ webex.internal.llm.getLocusUrl.returns('a url');
14527
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14528
+ webex.internal.llm.disconnectLLM.callsFake(async () => {
14529
+ webex.internal.llm.getOwnerMeetingId.returns('new-owner-id');
14530
+ throw new Error('disconnect failed');
14531
+ });
14532
+ meeting.locusInfo = {
14533
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14534
+ url: 'a different url',
14535
+ info: {datachannelUrl: 'a datachannel url'},
14536
+ self: {},
14537
+ };
14538
+
14539
+ try {
14540
+ await meeting.updateLLMConnection();
14541
+ } catch (e) {
14542
+ /* updateLLMConnection may reject when cleanup throws */
14543
+ }
14544
+
14545
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14546
+ assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id');
14547
+ });
14548
+
14549
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
14550
+ meeting.joinedWith = {state: 'JOINED'};
14551
+ webex.internal.llm.isConnected.returns(true);
14552
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14553
+ webex.internal.llm.getLocusUrl.returns('a url');
14554
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14555
+ meeting.locusInfo = {
14556
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
14557
+ url: 'a different url',
14558
+ info: {datachannelUrl: 'a datachannel url'},
14559
+ self: {},
14560
+ };
14561
+
14562
+ await meeting.updateLLMConnection();
14563
+
14564
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14565
+ code: 3050,
14566
+ reason: 'done (permanent)',
14567
+ }, 'llm-default-session', meeting.id);
14568
+ assert.calledWithExactly(
14569
+ webex.internal.llm.registerAndConnect,
14570
+ 'a different url',
14571
+ 'a datachannel url',
14572
+ undefined
14573
+ );
14574
+ // setOwnerMeetingId is called twice: first with undefined in
14575
+ // cleanupLLMConneciton's finally block (so a failed disconnect
14576
+ // cannot leave a stale owner), then with this meeting's id
14577
+ // after registerAndConnect resolves.
14578
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
14579
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
14580
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
14581
+ });
14582
+
14583
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
14584
+ meeting.joinedWith = {state: 'JOINED'};
14585
+ webex.internal.llm.isConnected.returns(false);
14586
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14587
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14588
+
14589
+ await meeting.updateLLMConnection();
14590
+
14591
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14592
+ assert.calledOnceWithExactly(
14593
+ webex.internal.llm.setRefreshHandler,
14594
+ sinon.match.func,
14595
+ 'llm-default-session',
14596
+ meeting.id
14597
+ );
14598
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14599
+ });
14600
+
14601
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
14602
+ // Defensive path: if the LLM reports not-connected but an old
14603
+ // ownerId is still present (e.g. race before a successful
14604
+ // connections.delete), this meeting can still claim a fresh
14605
+ // connection.
14606
+ meeting.joinedWith = {state: 'JOINED'};
14607
+ webex.internal.llm.isConnected.returns(false);
14608
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
14609
+ webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined);
14610
+ webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token');
14611
+ meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14612
+
14613
+ await meeting.updateLLMConnection();
14614
+
14615
+ assert.calledTwice(webex.internal.llm.getDatachannelToken);
14616
+ assert.calledWithExactly(
14617
+ webex.internal.llm.getDatachannelToken.firstCall,
14618
+ 'llm-default-session',
14619
+ meeting.id
14620
+ );
14621
+ assert.calledWithExactly(
14622
+ webex.internal.llm.getDatachannelToken.secondCall,
14623
+ 'llm-default-session'
14624
+ );
14625
+ assert.calledOnceWithExactly(
14626
+ webex.internal.llm.registerAndConnect,
14627
+ 'a url',
14628
+ 'a datachannel url',
14629
+ 'recovered-token'
14630
+ );
14631
+ assert.calledWithExactly(
14632
+ webex.internal.llm.setRefreshHandler.firstCall,
14633
+ sinon.match.func,
14634
+ 'llm-default-session',
14635
+ undefined
14636
+ );
14637
+ assert.calledTwice(webex.internal.llm.setRefreshHandler);
14638
+ assert.calledWithExactly(
14639
+ webex.internal.llm.setRefreshHandler.secondCall,
14640
+ sinon.match.func,
14641
+ 'llm-default-session',
14642
+ meeting.id
14643
+ );
14644
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14645
+ });
14646
+ });
14647
+
13196
14648
  describe('#clearMeetingData', () => {
13197
14649
  beforeEach(() => {
13198
14650
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13211,7 +14663,7 @@ describe('plugin-meetings', () => {
13211
14663
  assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13212
14664
  code: 3050,
13213
14665
  reason: 'done (permanent)',
13214
- });
14666
+ }, 'llm-default-session', meeting.id);
13215
14667
  assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13216
14668
  assert.calledWithExactly(
13217
14669
  webex.internal.llm.off,
@@ -13224,10 +14676,13 @@ describe('plugin-meetings', () => {
13224
14676
  meeting.processLocusLLMEvent
13225
14677
  );
13226
14678
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
- assert.calledOnce(meeting.stopTranscription);
13228
- assert.isUndefined(meeting.transcription);
13229
14679
  assert.calledOnce(meeting.clearDataChannelToken);
13230
- assert.calledOnce(meeting.annotation.deregisterEvents);
14680
+ // stopTranscription and annotation.deregisterEvents are not
14681
+ // called here: they run in stopListeningForMeetingEvents()
14682
+ // before /leave to avoid double-emitting
14683
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
14684
+ assert.notCalled(meeting.stopTranscription);
14685
+ assert.notCalled(meeting.annotation.deregisterEvents);
13231
14686
  });
13232
14687
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
14688
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13246,19 +14701,65 @@ describe('plugin-meetings', () => {
13246
14701
  meeting.processLocusLLMEvent
13247
14702
  );
13248
14703
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
- assert.calledOnce(meeting.stopTranscription);
13250
- assert.isUndefined(meeting.transcription);
13251
14704
  assert.calledOnce(meeting.clearDataChannelToken);
13252
- assert.calledOnce(meeting.annotation.deregisterEvents);
14705
+ assert.notCalled(meeting.stopTranscription);
14706
+ assert.notCalled(meeting.annotation.deregisterEvents);
13253
14707
  });
13254
- it('always calls stopTranscription even when transcription is undefined', async () => {
13255
- meeting.transcription = undefined;
13256
14708
 
13257
- await meeting.clearMeetingData();
14709
+ describe('ownership tag', () => {
14710
+ beforeEach(() => {
14711
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
14712
+ });
13258
14713
 
13259
- assert.calledOnce(meeting.stopTranscription);
13260
- assert.isUndefined(meeting.transcription);
13261
- assert.calledOnce(meeting.clearDataChannelToken);
14714
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
14715
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14716
+
14717
+ await meeting.clearMeetingData();
14718
+
14719
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14720
+ // clearDataChannelToken is always delegated; llm enforces
14721
+ // ownership and no-ops for non-owners internally.
14722
+ assert.calledOnce(meeting.clearDataChannelToken);
14723
+ // Listeners owned by *this* Meeting instance must still be
14724
+ // removed so a leaving subordinate meeting stops receiving
14725
+ // relay/locus events from the shared singleton.
14726
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14727
+ assert.calledWithExactly(
14728
+ webex.internal.llm.off,
14729
+ 'event:relay.event',
14730
+ meeting.processRelayEvent
14731
+ );
14732
+ assert.calledWithExactly(
14733
+ webex.internal.llm.off,
14734
+ 'event:locus.state_message',
14735
+ meeting.processLocusLLMEvent
14736
+ );
14737
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14738
+ });
14739
+
14740
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14741
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14742
+
14743
+ await meeting.clearMeetingData();
14744
+
14745
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14746
+ code: 3050,
14747
+ reason: 'done (permanent)',
14748
+ }, 'llm-default-session', meeting.id);
14749
+ assert.calledOnce(meeting.clearDataChannelToken);
14750
+ });
14751
+
14752
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14753
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14754
+
14755
+ await meeting.clearMeetingData();
14756
+
14757
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14758
+ code: 3050,
14759
+ reason: 'done (permanent)',
14760
+ }, 'llm-default-session', meeting.id);
14761
+ assert.calledOnce(meeting.clearDataChannelToken);
14762
+ });
13262
14763
  });
13263
14764
  });
13264
14765
  });
@@ -15012,16 +16513,25 @@ describe('plugin-meetings', () => {
15012
16513
  assert.notCalled(meeting.meetingRequest.sendReaction);
15013
16514
  });
15014
16515
 
15015
- it('should fail sending a reaction if reactionType is invalid ', async () => {
16516
+ it('should send a custom reaction type not in the known list', async () => {
15016
16517
  meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
15017
16518
 
15018
- await assert.isRejected(
15019
- meeting.sendReaction('invalid_reaction', 'light'),
15020
- Error,
15021
- 'invalid_reaction is not a valid reaction.'
15022
- );
16519
+ const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
15023
16520
 
15024
- assert.notCalled(meeting.meetingRequest.sendReaction);
16521
+ assert.exists(reactionPromise.then);
16522
+ await reactionPromise;
16523
+ assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
16524
+ reactionChannelUrl: 'Fake URL',
16525
+ reaction: {
16526
+ type: 'custom_reaction',
16527
+ tone: {
16528
+ type: 'light_skin_tone',
16529
+ codepoints: '1F3FB',
16530
+ shortcodes: ':skin-tone-2:',
16531
+ },
16532
+ },
16533
+ participantId: meeting.members.selfId,
16534
+ });
15025
16535
  });
15026
16536
 
15027
16537
  it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
@@ -16045,4 +17555,4 @@ describe('plugin-meetings', () => {
16045
17555
  assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
16046
17556
  });
16047
17557
  });
16048
- });
17558
+ });