@webex/plugin-meetings 3.12.0-next.6 → 3.12.0-next.60

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 (158) 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 +6 -3
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +10 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +716 -370
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +22 -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/locusRetry.js +23 -8
  27. package/dist/interceptors/locusRetry.js.map +1 -1
  28. package/dist/interpretation/index.js +10 -1
  29. package/dist/interpretation/index.js.map +1 -1
  30. package/dist/interpretation/siLanguage.js +1 -1
  31. package/dist/locus-info/controlsUtils.js +4 -1
  32. package/dist/locus-info/controlsUtils.js.map +1 -1
  33. package/dist/locus-info/index.js +289 -87
  34. package/dist/locus-info/index.js.map +1 -1
  35. package/dist/locus-info/types.js +19 -0
  36. package/dist/locus-info/types.js.map +1 -1
  37. package/dist/media/index.js +3 -1
  38. package/dist/media/index.js.map +1 -1
  39. package/dist/media/properties.js +1 -0
  40. package/dist/media/properties.js.map +1 -1
  41. package/dist/meeting/in-meeting-actions.js +3 -1
  42. package/dist/meeting/in-meeting-actions.js.map +1 -1
  43. package/dist/meeting/index.js +907 -535
  44. package/dist/meeting/index.js.map +1 -1
  45. package/dist/meeting/util.js +19 -2
  46. package/dist/meeting/util.js.map +1 -1
  47. package/dist/meetings/index.js +231 -78
  48. package/dist/meetings/index.js.map +1 -1
  49. package/dist/meetings/meetings.types.js +6 -1
  50. package/dist/meetings/meetings.types.js.map +1 -1
  51. package/dist/meetings/request.js +39 -0
  52. package/dist/meetings/request.js.map +1 -1
  53. package/dist/meetings/util.js +79 -5
  54. package/dist/meetings/util.js.map +1 -1
  55. package/dist/member/index.js +10 -0
  56. package/dist/member/index.js.map +1 -1
  57. package/dist/member/types.js.map +1 -1
  58. package/dist/member/util.js +3 -0
  59. package/dist/member/util.js.map +1 -1
  60. package/dist/metrics/constants.js +4 -1
  61. package/dist/metrics/constants.js.map +1 -1
  62. package/dist/multistream/codec/constants.js +63 -0
  63. package/dist/multistream/codec/constants.js.map +1 -0
  64. package/dist/multistream/mediaRequestManager.js +62 -15
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/receiveSlot.js +9 -0
  67. package/dist/multistream/receiveSlot.js.map +1 -1
  68. package/dist/reactions/reactions.type.js.map +1 -1
  69. package/dist/recording-controller/index.js +1 -3
  70. package/dist/recording-controller/index.js.map +1 -1
  71. package/dist/types/config.d.ts +2 -0
  72. package/dist/types/constants.d.ts +2 -0
  73. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  74. package/dist/types/controls-options-manager/index.d.ts +10 -0
  75. package/dist/types/hashTree/constants.d.ts +1 -0
  76. package/dist/types/hashTree/hashTreeParser.d.ts +92 -16
  77. package/dist/types/hashTree/utils.d.ts +11 -0
  78. package/dist/types/index.d.ts +2 -0
  79. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  80. package/dist/types/locus-info/index.d.ts +46 -6
  81. package/dist/types/locus-info/types.d.ts +21 -1
  82. package/dist/types/media/properties.d.ts +1 -0
  83. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  84. package/dist/types/meeting/index.d.ts +87 -3
  85. package/dist/types/meeting/util.d.ts +8 -0
  86. package/dist/types/meetings/index.d.ts +30 -2
  87. package/dist/types/meetings/meetings.types.d.ts +15 -0
  88. package/dist/types/meetings/request.d.ts +14 -0
  89. package/dist/types/member/index.d.ts +1 -0
  90. package/dist/types/member/types.d.ts +1 -0
  91. package/dist/types/member/util.d.ts +1 -0
  92. package/dist/types/metrics/constants.d.ts +3 -0
  93. package/dist/types/multistream/codec/constants.d.ts +7 -0
  94. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  95. package/dist/types/reactions/reactions.type.d.ts +3 -0
  96. package/dist/webinar/index.js +361 -235
  97. package/dist/webinar/index.js.map +1 -1
  98. package/package.json +22 -22
  99. package/src/aiEnableRequest/index.ts +16 -0
  100. package/src/breakouts/breakout.ts +3 -1
  101. package/src/breakouts/index.ts +31 -0
  102. package/src/config.ts +2 -0
  103. package/src/constants.ts +5 -1
  104. package/src/controls-options-manager/constants.ts +14 -1
  105. package/src/controls-options-manager/index.ts +47 -24
  106. package/src/controls-options-manager/util.ts +81 -1
  107. package/src/hashTree/constants.ts +9 -0
  108. package/src/hashTree/hashTreeParser.ts +429 -183
  109. package/src/hashTree/utils.ts +17 -0
  110. package/src/index.ts +5 -0
  111. package/src/interceptors/locusRetry.ts +25 -4
  112. package/src/interpretation/index.ts +25 -8
  113. package/src/locus-info/controlsUtils.ts +3 -1
  114. package/src/locus-info/index.ts +291 -97
  115. package/src/locus-info/types.ts +25 -1
  116. package/src/media/index.ts +3 -0
  117. package/src/media/properties.ts +1 -0
  118. package/src/meeting/in-meeting-actions.ts +4 -0
  119. package/src/meeting/index.ts +388 -33
  120. package/src/meeting/util.ts +20 -2
  121. package/src/meetings/index.ts +134 -44
  122. package/src/meetings/meetings.types.ts +19 -0
  123. package/src/meetings/request.ts +43 -0
  124. package/src/meetings/util.ts +97 -1
  125. package/src/member/index.ts +10 -0
  126. package/src/member/types.ts +1 -0
  127. package/src/member/util.ts +3 -0
  128. package/src/metrics/constants.ts +3 -0
  129. package/src/multistream/codec/constants.ts +58 -0
  130. package/src/multistream/mediaRequestManager.ts +119 -28
  131. package/src/multistream/receiveSlot.ts +18 -0
  132. package/src/reactions/reactions.type.ts +3 -0
  133. package/src/recording-controller/index.ts +1 -2
  134. package/src/webinar/index.ts +162 -21
  135. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  136. package/test/unit/spec/breakouts/breakout.ts +9 -3
  137. package/test/unit/spec/breakouts/index.ts +49 -0
  138. package/test/unit/spec/controls-options-manager/index.js +140 -29
  139. package/test/unit/spec/controls-options-manager/util.js +165 -0
  140. package/test/unit/spec/hashTree/hashTreeParser.ts +1508 -149
  141. package/test/unit/spec/hashTree/utils.ts +88 -1
  142. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  143. package/test/unit/spec/interpretation/index.ts +26 -4
  144. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  145. package/test/unit/spec/locus-info/index.js +475 -81
  146. package/test/unit/spec/media/index.ts +31 -0
  147. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  148. package/test/unit/spec/meeting/index.js +1131 -49
  149. package/test/unit/spec/meeting/muteState.js +3 -0
  150. package/test/unit/spec/meeting/utils.js +33 -0
  151. package/test/unit/spec/meetings/index.js +360 -10
  152. package/test/unit/spec/meetings/request.js +141 -0
  153. package/test/unit/spec/meetings/utils.js +189 -0
  154. package/test/unit/spec/member/index.js +7 -0
  155. package/test/unit/spec/member/util.js +24 -0
  156. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  157. package/test/unit/spec/recording-controller/index.js +9 -8
  158. package/test/unit/spec/webinar/index.ts +141 -16
@@ -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,
@@ -417,6 +420,160 @@ describe('plugin-meetings', () => {
417
420
  assert.instanceOf(meeting.mediaRequestManagers.screenShareVideo, MediaRequestManager);
418
421
  });
419
422
 
423
+ it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 when sending multistream video requests', () => {
424
+ const getIngressPayloadType = sinon.stub().returns(97);
425
+
426
+ meeting.isMultistream = true;
427
+ meeting.mediaProperties.webrtcMediaConnection = {
428
+ getIngressPayloadType,
429
+ requestMedia: sinon.stub(),
430
+ };
431
+
432
+ const fakeReceiveSlot = {
433
+ on: sinon.stub(),
434
+ off: sinon.stub(),
435
+ sourceState: 'live',
436
+ mediaType: MediaType.VideoMain,
437
+ wcmeReceiveSlot: {id: 'fake-wcme-slot'},
438
+ };
439
+
440
+ meeting.mediaRequestManagers.video.addRequest(
441
+ {
442
+ policyInfo: {
443
+ policy: 'receiver-selected',
444
+ csi: 42,
445
+ },
446
+ receiveSlots: [fakeReceiveSlot],
447
+ codecInfo: {
448
+ codec: 'h264',
449
+ maxFs: 3600,
450
+ },
451
+ },
452
+ true
453
+ );
454
+
455
+ assert.calledOnceWithExactly(
456
+ getIngressPayloadType,
457
+ MediaType.VideoMain,
458
+ MediaCodecMimeType.H264
459
+ );
460
+ });
461
+
462
+ it('getIngressPayloadType on webrtcMediaConnection is invoked for H264 and AV1 for slides video when AV1 slides support is enabled', () => {
463
+ const localWebex = new MockWebex({
464
+ children: {
465
+ meetings: Meetings,
466
+ credentials: Credentials,
467
+ support: Support,
468
+ llm: LLM,
469
+ mercury: Mercury,
470
+ },
471
+ config: {
472
+ credentials: {
473
+ client_id: 'mock-client-id',
474
+ },
475
+ meetings: {
476
+ reconnection: {
477
+ enabled: false,
478
+ },
479
+ mediaSettings: {},
480
+ metrics: {},
481
+ stats: {},
482
+ experimental: {enableUnifiedMeetings: true},
483
+ degradationPreferences: {maxMacroblocksLimit: 8192},
484
+ enableAv1SlidesSupport: true,
485
+ },
486
+ metrics: {
487
+ type: ['behavioral'],
488
+ },
489
+ },
490
+ });
491
+
492
+ localWebex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub();
493
+ localWebex.internal.newMetrics.callDiagnosticMetrics.clearEventLimitsForCorrelationId =
494
+ sinon.stub();
495
+ localWebex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve());
496
+ localWebex.internal.services = {get: sinon.stub().returns('locus-url')};
497
+ localWebex.credentials.getOrgId = sinon.stub().returns('fake-org-id');
498
+ localWebex.internal.metrics.submitClientMetrics = sinon.stub().returns(Promise.resolve());
499
+ localWebex.meetings.uploadLogs = sinon.stub().returns(Promise.resolve());
500
+ localWebex.meetings.reachability = {
501
+ isAnyPublicClusterReachable: sinon.stub().resolves(true),
502
+ getReachabilityResults: sinon.stub().resolves(undefined),
503
+ getReachabilityMetrics: sinon.stub().resolves({}),
504
+ stopReachability: sinon.stub(),
505
+ isSubnetReachable: sinon.stub().returns(true),
506
+ };
507
+ localWebex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
508
+ localWebex.internal.llm.on = sinon.stub();
509
+ localWebex.internal.voicea.announce = sinon.stub();
510
+ localWebex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
511
+ {},
512
+ {parent: localWebex}
513
+ );
514
+
515
+ Metrics.initialSetup(localWebex);
516
+
517
+ const localMeeting = new Meeting(
518
+ {
519
+ userId: uuid1,
520
+ resource: uuid2,
521
+ deviceUrl: uuid3,
522
+ locus: {url: url1},
523
+ destination: testDestination,
524
+ destinationType: DESTINATION_TYPE.MEETING_ID,
525
+ correlationId,
526
+ selfId: uuid1,
527
+ },
528
+ {
529
+ parent: localWebex,
530
+ }
531
+ );
532
+
533
+ const getIngressPayloadType = sinon.stub().callsFake((_mediaType, codecMimeType) => {
534
+ if (codecMimeType === MediaCodecMimeType.H264) {
535
+ return 97;
536
+ }
537
+ if (codecMimeType === MediaCodecMimeType.AV1) {
538
+ return 98;
539
+ }
540
+
541
+ return undefined;
542
+ });
543
+
544
+ localMeeting.isMultistream = true;
545
+ localMeeting.mediaProperties.webrtcMediaConnection = {
546
+ getIngressPayloadType,
547
+ requestMedia: sinon.stub(),
548
+ };
549
+
550
+ const fakeReceiveSlot = {
551
+ on: sinon.stub(),
552
+ off: sinon.stub(),
553
+ sourceState: 'live',
554
+ mediaType: MediaType.VideoSlides,
555
+ wcmeReceiveSlot: {id: 'fake-wcme-slides-slot'},
556
+ };
557
+
558
+ localMeeting.mediaRequestManagers.screenShareVideo.addRequest(
559
+ {
560
+ policyInfo: {
561
+ policy: 'receiver-selected',
562
+ csi: 42,
563
+ },
564
+ receiveSlots: [fakeReceiveSlot],
565
+ codecInfo: {
566
+ codec: 'h264',
567
+ maxFs: 3600,
568
+ },
569
+ },
570
+ true
571
+ );
572
+
573
+ assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.H264);
574
+ assert.calledWith(getIngressPayloadType, MediaType.VideoSlides, MediaCodecMimeType.AV1);
575
+ });
576
+
420
577
  it('uses meeting id as correlation id if not provided in constructor', () => {
421
578
  const newMeeting = new Meeting(
422
579
  {
@@ -1977,16 +2134,124 @@ describe('plugin-meetings', () => {
1977
2134
  fakeProcessedReaction
1978
2135
  );
1979
2136
  });
2137
+
2138
+ [
2139
+ {
2140
+ title: 'should skip a reaction when the default relay route does not match the LLM binding',
2141
+ isPracticeSessionConnected: false,
2142
+ route: 'wrong-default-route',
2143
+ defaultBinding: 'default-route',
2144
+ practiceBinding: 'practice-route',
2145
+ shouldProcess: false,
2146
+ expectedSessionLabel: 'default session',
2147
+ },
2148
+ {
2149
+ title: 'should process a reaction when the default relay route matches the LLM binding',
2150
+ isPracticeSessionConnected: false,
2151
+ route: 'default-route',
2152
+ defaultBinding: 'default-route',
2153
+ practiceBinding: 'practice-route',
2154
+ shouldProcess: true,
2155
+ },
2156
+ {
2157
+ title:
2158
+ 'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
2159
+ isPracticeSessionConnected: true,
2160
+ route: 'practice-route',
2161
+ defaultBinding: 'default-route',
2162
+ practiceBinding: 'practice-route',
2163
+ shouldProcess: true,
2164
+ },
2165
+ {
2166
+ title:
2167
+ 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
2168
+ isPracticeSessionConnected: true,
2169
+ route: 'default-route',
2170
+ defaultBinding: 'default-route',
2171
+ practiceBinding: 'practice-route',
2172
+ shouldProcess: false,
2173
+ expectedSessionLabel: 'practice session',
2174
+ },
2175
+ ].forEach(
2176
+ ({
2177
+ title,
2178
+ isPracticeSessionConnected,
2179
+ route,
2180
+ defaultBinding,
2181
+ practiceBinding,
2182
+ shouldProcess,
2183
+ expectedSessionLabel,
2184
+ }) => {
2185
+ it(title, () => {
2186
+ meeting.isReactionsSupported = sinon.stub().returns(true);
2187
+ meeting.config.receiveReactions = true;
2188
+ const fakeSendersName = 'Fake reactors name';
2189
+ meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
2190
+ webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
2191
+ return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
2192
+ });
2193
+ webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
2194
+ if (llmSessionId === LLM_PRACTICE_SESSION) {
2195
+ return practiceBinding;
2196
+ }
2197
+
2198
+ return defaultBinding;
2199
+ });
2200
+ const fakeReactionPayload = {
2201
+ type: 'fake_type',
2202
+ codepoints: 'fake_codepoints',
2203
+ shortcodes: 'fake_shortcodes',
2204
+ };
2205
+ const fakeSenderPayload = {
2206
+ participantId: 'fake_participant_id',
2207
+ };
2208
+ const fakeRelayEvent = {
2209
+ headers: {route},
2210
+ data: {
2211
+ relayType: REACTION_RELAY_TYPES.REACTION,
2212
+ reaction: fakeReactionPayload,
2213
+ sender: fakeSenderPayload,
2214
+ },
2215
+ };
2216
+ const fakeProcessedReaction = {
2217
+ reaction: fakeReactionPayload,
2218
+ sender: {
2219
+ id: fakeSenderPayload.participantId,
2220
+ name: fakeSendersName,
2221
+ },
2222
+ };
2223
+
2224
+ TriggerProxy.trigger.resetHistory();
2225
+ meeting.processRelayEvent(fakeRelayEvent);
2226
+
2227
+ if (shouldProcess) {
2228
+ assert.calledWith(
2229
+ TriggerProxy.trigger,
2230
+ sinon.match.instanceOf(Meeting),
2231
+ {
2232
+ file: 'meeting/index',
2233
+ function: 'join',
2234
+ },
2235
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
2236
+ fakeProcessedReaction
2237
+ );
2238
+ } else {
2239
+ assert.notCalled(TriggerProxy.trigger);
2240
+ }
2241
+ });
2242
+ }
2243
+ );
1980
2244
  });
1981
2245
 
1982
2246
  describe('#handleLLMOnline', () => {
1983
2247
  beforeEach(() => {
1984
2248
  webex.internal.llm.off = sinon.stub();
2249
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
2250
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
1985
2251
  });
1986
2252
 
1987
- it('turns off llm online, emits transcription connected events', () => {
2253
+ it('emits transcription connected events', () => {
1988
2254
  meeting.handleLLMOnline();
1989
- assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
1990
2255
  assert.calledWith(
1991
2256
  TriggerProxy.trigger,
1992
2257
  sinon.match.instanceOf(Meeting),
@@ -1997,6 +2262,24 @@ describe('plugin-meetings', () => {
1997
2262
  EVENT_TRIGGERS.MEETING_TRANSCRIPTION_CONNECTED
1998
2263
  );
1999
2264
  });
2265
+
2266
+ it('restores transcription subscription when caption intent is enabled', () => {
2267
+ webex.internal.voicea.getIsCaptionBoxOn.returns(true);
2268
+
2269
+ meeting.handleLLMOnline();
2270
+
2271
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, {
2272
+ subscribe: ['transcription'],
2273
+ });
2274
+ });
2275
+
2276
+ it('does not restore transcription subscription when caption intent is disabled', () => {
2277
+ webex.internal.voicea.getIsCaptionBoxOn.returns(false);
2278
+
2279
+ meeting.handleLLMOnline();
2280
+
2281
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2282
+ });
2000
2283
  });
2001
2284
 
2002
2285
  describe('#join', () => {
@@ -2016,6 +2299,7 @@ describe('plugin-meetings', () => {
2016
2299
  it('should have #join', () => {
2017
2300
  assert.exists(meeting.join);
2018
2301
  });
2302
+
2019
2303
  beforeEach(() => {
2020
2304
  setCorrelationIdSpy = sinon.spy(meeting, 'setCorrelationId');
2021
2305
  meeting.setLocus = sinon.stub().returns(true);
@@ -2169,7 +2453,6 @@ describe('plugin-meetings', () => {
2169
2453
  await meeting.join().catch(() => {
2170
2454
  assert.calledOnce(MeetingUtil.joinMeeting);
2171
2455
 
2172
- // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
2173
2456
  assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
2174
2457
  assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
2175
2458
  name: 'client.call.initiated',
@@ -2201,6 +2484,7 @@ describe('plugin-meetings', () => {
2201
2484
  });
2202
2485
  });
2203
2486
  });
2487
+
2204
2488
  describe('lmm, transcription & permissionTokenRefresh decoupling', () => {
2205
2489
  beforeEach(() => {
2206
2490
  sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult));
@@ -2271,7 +2555,6 @@ describe('plugin-meetings', () => {
2271
2555
  const locusInfoParseStub = sinon.stub(meeting.locusInfo, 'parse');
2272
2556
  sinon.stub(meeting, 'isJoined').returns(true);
2273
2557
 
2274
- // Set up llm.on stub to capture the registered listener when updateLLMConnection is called
2275
2558
  let locusLLMEventListener;
2276
2559
  meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => {
2277
2560
  if (eventName === 'event:locus.state_message') {
@@ -2280,16 +2563,12 @@ describe('plugin-meetings', () => {
2280
2563
  });
2281
2564
  meeting.webex.internal.llm.off = sinon.stub();
2282
2565
 
2283
- // we need the real meeting.updateLLMConnection not the mock
2284
2566
  meeting.updateLLMConnection.restore();
2285
2567
 
2286
- // Call updateLLMConnection to register the listener
2287
2568
  await meeting.updateLLMConnection();
2288
2569
 
2289
- // Verify the listener was registered and we captured it
2290
2570
  assert.isDefined(locusLLMEventListener, 'LLM event listener should be registered');
2291
2571
 
2292
- // Now trigger the event
2293
2572
  const eventData = {
2294
2573
  eventType: 'locus.state_message',
2295
2574
  stateElementsMessage: {
@@ -2309,13 +2588,10 @@ describe('plugin-meetings', () => {
2309
2588
  sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true);
2310
2589
  sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({});
2311
2590
 
2312
- // Restore the real updateLLMConnection
2313
2591
  meeting.updateLLMConnection.restore();
2314
2592
 
2315
- // Call updateLLMConnection to start the timer
2316
2593
  await meeting.updateLLMConnection();
2317
2594
 
2318
- // Fast forward time by 3 minutes
2319
2595
  fakeClock.tick(3 * 60 * 1000);
2320
2596
 
2321
2597
  assert.calledWith(
@@ -2340,18 +2616,14 @@ describe('plugin-meetings', () => {
2340
2616
  .stub(meeting.webex.internal.llm, 'getDatachannelUrl')
2341
2617
  .returns('https://datachannel1.example.com');
2342
2618
 
2343
- // Restore the real updateLLMConnection
2344
2619
  meeting.updateLLMConnection.restore();
2345
2620
 
2346
- // First, connect LLM and start the timer
2347
2621
  isJoinedStub.returns(true);
2348
2622
  meeting.webex.internal.llm.isConnected.returns(false);
2349
2623
  await meeting.updateLLMConnection();
2350
2624
 
2351
- // Verify timer was started
2352
2625
  assert.exists(meeting.llmHealthCheckTimer);
2353
2626
 
2354
- // Now simulate that we're no longer joined
2355
2627
  isJoinedStub.returns(false);
2356
2628
  meeting.webex.internal.llm.isConnected.returns(true);
2357
2629
 
@@ -2359,10 +2631,8 @@ describe('plugin-meetings', () => {
2359
2631
 
2360
2632
  assert.calledOnce(meeting.webex.internal.llm.disconnectLLM);
2361
2633
 
2362
- // Verify the timer was cleared (should be undefined)
2363
2634
  assert.isUndefined(meeting.llmHealthCheckTimer);
2364
2635
 
2365
- // Fast forward time to ensure no metric is sent
2366
2636
  Metrics.sendBehavioralMetric.resetHistory();
2367
2637
  fakeClock.tick(3 * 60 * 1000);
2368
2638
 
@@ -2397,7 +2667,6 @@ describe('plugin-meetings', () => {
2397
2667
  .stub()
2398
2668
  .rejects(new CaptchaError('bad captcha'));
2399
2669
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2400
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil, 'joinMeetingOptions');
2401
2670
 
2402
2671
  try {
2403
2672
  await meeting.join();
@@ -2411,8 +2680,7 @@ describe('plugin-meetings', () => {
2411
2680
  );
2412
2681
  assert.instanceOf(error, CaptchaError);
2413
2682
  assert.equal(error.message, 'bad captcha');
2414
- // should not get to the end promise chain, which does do the join
2415
- assert.notCalled(joinMeetingOptionsSpy);
2683
+ assert.notCalled(MeetingUtil.joinMeeting);
2416
2684
  }
2417
2685
  });
2418
2686
 
@@ -2421,7 +2689,6 @@ describe('plugin-meetings', () => {
2421
2689
  .stub()
2422
2690
  .rejects(new PasswordError('bad password'));
2423
2691
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2424
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2425
2692
 
2426
2693
  try {
2427
2694
  await meeting.join();
@@ -2435,8 +2702,7 @@ describe('plugin-meetings', () => {
2435
2702
  );
2436
2703
  assert.instanceOf(error, PasswordError);
2437
2704
  assert.equal(error.message, 'bad password');
2438
- // should not get to the end promise chain, which does do the join
2439
- assert.notCalled(joinMeetingOptionsSpy);
2705
+ assert.notCalled(MeetingUtil.joinMeeting);
2440
2706
  }
2441
2707
  });
2442
2708
 
@@ -2445,7 +2711,6 @@ describe('plugin-meetings', () => {
2445
2711
  .stub()
2446
2712
  .rejects(new PermissionError('bad permission'));
2447
2713
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2448
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2449
2714
 
2450
2715
  try {
2451
2716
  await meeting.join();
@@ -2459,14 +2724,14 @@ describe('plugin-meetings', () => {
2459
2724
  );
2460
2725
  assert.instanceOf(error, PermissionError);
2461
2726
  assert.equal(error.message, 'bad permission');
2462
- // should not get to the end promise chain, which does do the join
2463
- assert.notCalled(joinMeetingOptionsSpy);
2727
+ assert.notCalled(MeetingUtil.joinMeeting);
2464
2728
  }
2465
2729
  });
2466
2730
  });
2467
2731
  });
2468
2732
  });
2469
2733
 
2734
+
2470
2735
  describe('#addMedia', () => {
2471
2736
  const muteStateStub = {
2472
2737
  handleClientRequest: sinon.stub().returns(Promise.resolve(true)),
@@ -4533,6 +4798,297 @@ describe('plugin-meetings', () => {
4533
4798
  },
4534
4799
  });
4535
4800
  });
4801
+
4802
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
4803
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
4804
+ const fakeStats = new Map([
4805
+ [
4806
+ 'transport-1',
4807
+ {
4808
+ type: 'transport',
4809
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4810
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4811
+ },
4812
+ ],
4813
+ [
4814
+ 'outbound-rtp-1',
4815
+ {
4816
+ type: 'outbound-rtp',
4817
+ ssrc: 12345,
4818
+ },
4819
+ ],
4820
+ ]);
4821
+
4822
+ statsAnalyzerStub.emit(
4823
+ {file: 'test', function: 'test'},
4824
+ StatsAnalyzerEventNames.STATS_UPDATE,
4825
+ {stats: fakeStats}
4826
+ );
4827
+
4828
+ assert.calledWith(
4829
+ TriggerProxy.trigger,
4830
+ sinon.match.instanceOf(Meeting),
4831
+ {
4832
+ file: 'meeting/index',
4833
+ function: 'setupStatsAnalyzerEventHandlers',
4834
+ },
4835
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4836
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4837
+ );
4838
+
4839
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4840
+ });
4841
+
4842
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
4843
+ const firstStats = new Map([
4844
+ [
4845
+ 'transport-1',
4846
+ {
4847
+ type: 'transport',
4848
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4849
+ },
4850
+ ],
4851
+ ]);
4852
+
4853
+ statsAnalyzerStub.emit(
4854
+ {file: 'test', function: 'test'},
4855
+ StatsAnalyzerEventNames.STATS_UPDATE,
4856
+ {stats: firstStats}
4857
+ );
4858
+
4859
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4860
+
4861
+ const secondStats = new Map([
4862
+ [
4863
+ 'transport-1',
4864
+ {
4865
+ type: 'transport',
4866
+ srtpCipher: 'AEAD_AES_256_GCM',
4867
+ },
4868
+ ],
4869
+ ]);
4870
+
4871
+ TriggerProxy.trigger.resetHistory();
4872
+
4873
+ statsAnalyzerStub.emit(
4874
+ {file: 'test', function: 'test'},
4875
+ StatsAnalyzerEventNames.STATS_UPDATE,
4876
+ {stats: secondStats}
4877
+ );
4878
+
4879
+ assert.calledWith(
4880
+ TriggerProxy.trigger,
4881
+ sinon.match.instanceOf(Meeting),
4882
+ {
4883
+ file: 'meeting/index',
4884
+ function: 'setupStatsAnalyzerEventHandlers',
4885
+ },
4886
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4887
+ {srtpCipher: 'AEAD_AES_256_GCM'}
4888
+ );
4889
+
4890
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
4891
+ });
4892
+
4893
+ it('does not emit event when srtpCipher has not changed', async () => {
4894
+ const firstStats = new Map([
4895
+ [
4896
+ 'transport-1',
4897
+ {
4898
+ type: 'transport',
4899
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4900
+ },
4901
+ ],
4902
+ ]);
4903
+
4904
+ statsAnalyzerStub.emit(
4905
+ {file: 'test', function: 'test'},
4906
+ StatsAnalyzerEventNames.STATS_UPDATE,
4907
+ {stats: firstStats}
4908
+ );
4909
+
4910
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4911
+
4912
+ TriggerProxy.trigger.resetHistory();
4913
+
4914
+ // Emit same cipher again
4915
+ statsAnalyzerStub.emit(
4916
+ {file: 'test', function: 'test'},
4917
+ StatsAnalyzerEventNames.STATS_UPDATE,
4918
+ {stats: firstStats}
4919
+ );
4920
+
4921
+ // Should not trigger event again
4922
+ assert.neverCalledWith(
4923
+ TriggerProxy.trigger,
4924
+ sinon.match.instanceOf(Meeting),
4925
+ sinon.match.any,
4926
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4927
+ sinon.match.any
4928
+ );
4929
+
4930
+ // Cipher should remain the same
4931
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4932
+ });
4933
+
4934
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
4935
+ const fakeStats = new Map([
4936
+ [
4937
+ 'outbound-rtp-1',
4938
+ {
4939
+ type: 'outbound-rtp',
4940
+ ssrc: 12345,
4941
+ },
4942
+ ],
4943
+ [
4944
+ 'inbound-rtp-1',
4945
+ {
4946
+ type: 'inbound-rtp',
4947
+ ssrc: 67890,
4948
+ },
4949
+ ],
4950
+ ]);
4951
+
4952
+ statsAnalyzerStub.emit(
4953
+ {file: 'test', function: 'test'},
4954
+ StatsAnalyzerEventNames.STATS_UPDATE,
4955
+ {stats: fakeStats}
4956
+ );
4957
+
4958
+ assert.neverCalledWith(
4959
+ TriggerProxy.trigger,
4960
+ sinon.match.instanceOf(Meeting),
4961
+ sinon.match.any,
4962
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4963
+ sinon.match.any
4964
+ );
4965
+
4966
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4967
+ });
4968
+
4969
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
4970
+ const fakeStats = new Map([
4971
+ [
4972
+ 'transport-1',
4973
+ {
4974
+ type: 'transport',
4975
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4976
+ // no srtpCipher property
4977
+ },
4978
+ ],
4979
+ ]);
4980
+
4981
+ statsAnalyzerStub.emit(
4982
+ {file: 'test', function: 'test'},
4983
+ StatsAnalyzerEventNames.STATS_UPDATE,
4984
+ {stats: fakeStats}
4985
+ );
4986
+
4987
+ assert.neverCalledWith(
4988
+ TriggerProxy.trigger,
4989
+ sinon.match.instanceOf(Meeting),
4990
+ sinon.match.any,
4991
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4992
+ sinon.match.any
4993
+ );
4994
+
4995
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4996
+ });
4997
+
4998
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
4999
+ const fakeStats = new Map([
5000
+ [
5001
+ 'transport-1',
5002
+ {
5003
+ type: 'transport',
5004
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
5005
+ },
5006
+ ],
5007
+ [
5008
+ 'transport-2',
5009
+ {
5010
+ type: 'transport',
5011
+ srtpCipher: 'AEAD_AES_256_GCM',
5012
+ },
5013
+ ],
5014
+ [
5015
+ 'outbound-rtp-1',
5016
+ {
5017
+ type: 'outbound-rtp',
5018
+ ssrc: 12345,
5019
+ },
5020
+ ],
5021
+ ]);
5022
+
5023
+ statsAnalyzerStub.emit(
5024
+ {file: 'test', function: 'test'},
5025
+ StatsAnalyzerEventNames.STATS_UPDATE,
5026
+ {stats: fakeStats}
5027
+ );
5028
+
5029
+ assert.calledWith(
5030
+ TriggerProxy.trigger,
5031
+ sinon.match.instanceOf(Meeting),
5032
+ {
5033
+ file: 'meeting/index',
5034
+ function: 'setupStatsAnalyzerEventHandlers',
5035
+ },
5036
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5037
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
5038
+ );
5039
+
5040
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
5041
+ });
5042
+
5043
+ it('handles empty stats map without errors', async () => {
5044
+ const emptyStats = new Map();
5045
+
5046
+ statsAnalyzerStub.emit(
5047
+ {file: 'test', function: 'test'},
5048
+ StatsAnalyzerEventNames.STATS_UPDATE,
5049
+ {stats: emptyStats}
5050
+ );
5051
+
5052
+ assert.neverCalledWith(
5053
+ TriggerProxy.trigger,
5054
+ sinon.match.instanceOf(Meeting),
5055
+ sinon.match.any,
5056
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
5057
+ sinon.match.any
5058
+ );
5059
+
5060
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
5061
+ });
5062
+
5063
+ it('logs cipher change when cipher is updated', async () => {
5064
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
5065
+
5066
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
5067
+
5068
+ const newStats = new Map([
5069
+ [
5070
+ 'transport-1',
5071
+ {
5072
+ type: 'transport',
5073
+ srtpCipher: 'AEAD_AES_256_GCM',
5074
+ },
5075
+ ],
5076
+ ]);
5077
+
5078
+ statsAnalyzerStub.emit(
5079
+ {file: 'test', function: 'test'},
5080
+ StatsAnalyzerEventNames.STATS_UPDATE,
5081
+ {stats: newStats}
5082
+ );
5083
+
5084
+ assert.calledWithMatch(
5085
+ loggerSpy,
5086
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
5087
+ );
5088
+
5089
+ loggerSpy.restore();
5090
+ });
5091
+ });
4536
5092
  });
4537
5093
 
4538
5094
  describe('handles StatsMonitor events', () => {
@@ -6428,6 +6984,9 @@ describe('plugin-meetings', () => {
6428
6984
 
6429
6985
  meeting.annotation.deregisterEvents = sinon.stub();
6430
6986
  webex.internal.llm.off = sinon.stub();
6987
+ webex.internal.mercury.off = sinon.stub();
6988
+ meeting.mercuryOnlineHandler = sinon.stub();
6989
+ meeting.mercuryOfflineHandler = sinon.stub();
6431
6990
 
6432
6991
  // A meeting needs to be joined to leave
6433
6992
  meeting.meetingState = 'ACTIVE';
@@ -6451,6 +7010,67 @@ describe('plugin-meetings', () => {
6451
7010
  assert.calledOnce(meeting.clearMeetingData);
6452
7011
  });
6453
7012
 
7013
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
7014
+ const onlineHandler = meeting.mercuryOnlineHandler;
7015
+ const offlineHandler = meeting.mercuryOfflineHandler;
7016
+
7017
+ await meeting.leave();
7018
+
7019
+ // All llm/mercury consumers (direct listeners, voicea transcription,
7020
+ // annotation) must be detached before the /leave request so that
7021
+ // in-flight events do not trigger unnecessary Locus syncs
7022
+ // (per Locus team recommendation).
7023
+ assert.callOrder(
7024
+ webex.internal.llm.off,
7025
+ webex.internal.mercury.off,
7026
+ meeting.stopTranscription,
7027
+ meeting.annotation.deregisterEvents,
7028
+ meeting.meetingRequest.leaveMeeting
7029
+ );
7030
+ assert.calledWithExactly(
7031
+ webex.internal.llm.off,
7032
+ 'event:relay.event',
7033
+ meeting.processRelayEvent
7034
+ );
7035
+ assert.calledWithExactly(
7036
+ webex.internal.llm.off,
7037
+ LOCUS_LLM_EVENT,
7038
+ meeting.processLocusLLMEvent
7039
+ );
7040
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7041
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7042
+ assert.isUndefined(meeting.mercuryOnlineHandler);
7043
+ assert.isUndefined(meeting.mercuryOfflineHandler);
7044
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7045
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7046
+ assert.isUndefined(meeting.transcription);
7047
+ });
7048
+
7049
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
7050
+ const onlineHandler = meeting.mercuryOnlineHandler;
7051
+ const offlineHandler = meeting.mercuryOfflineHandler;
7052
+ meeting.meetingRequest.leaveMeeting = sinon
7053
+ .stub()
7054
+ .returns(Promise.reject(new Error('leave failed')));
7055
+
7056
+ await meeting.leave().catch(() => {});
7057
+
7058
+ assert.calledWithExactly(
7059
+ webex.internal.llm.off,
7060
+ 'event:relay.event',
7061
+ meeting.processRelayEvent
7062
+ );
7063
+ assert.calledWithExactly(
7064
+ webex.internal.llm.off,
7065
+ LOCUS_LLM_EVENT,
7066
+ meeting.processLocusLLMEvent
7067
+ );
7068
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
7069
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
7070
+ assert.calledOnceWithExactly(meeting.stopTranscription);
7071
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
7072
+ });
7073
+
6454
7074
  it('should reset call diagnostic latencies correctly', async () => {
6455
7075
  const leave = meeting.leave();
6456
7076
 
@@ -8458,6 +9078,9 @@ describe('plugin-meetings', () => {
8458
9078
 
8459
9079
  meeting.annotation.deregisterEvents = sinon.stub();
8460
9080
  webex.internal.llm.off = sinon.stub();
9081
+ webex.internal.mercury.off = sinon.stub();
9082
+ meeting.mercuryOnlineHandler = sinon.stub();
9083
+ meeting.mercuryOfflineHandler = sinon.stub();
8461
9084
 
8462
9085
  // A meeting needs to be joined to end
8463
9086
  meeting.meetingState = 'ACTIVE';
@@ -8480,6 +9103,66 @@ describe('plugin-meetings', () => {
8480
9103
  assert.calledOnce(meeting?.unsetPeerConnections);
8481
9104
  assert.calledOnce(meeting?.clearMeetingData);
8482
9105
  });
9106
+
9107
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
9108
+ const onlineHandler = meeting.mercuryOnlineHandler;
9109
+ const offlineHandler = meeting.mercuryOfflineHandler;
9110
+
9111
+ await meeting.endMeetingForAll();
9112
+
9113
+ // All llm/mercury consumers (direct listeners, voicea transcription,
9114
+ // annotation) must be detached before the /end request so that
9115
+ // in-flight events do not trigger unnecessary Locus syncs
9116
+ // (per Locus team recommendation).
9117
+ assert.callOrder(
9118
+ webex.internal.llm.off,
9119
+ webex.internal.mercury.off,
9120
+ meeting.stopTranscription,
9121
+ meeting.annotation.deregisterEvents,
9122
+ meeting.meetingRequest.endMeetingForAll
9123
+ );
9124
+ assert.calledWithExactly(
9125
+ webex.internal.llm.off,
9126
+ 'event:relay.event',
9127
+ meeting.processRelayEvent
9128
+ );
9129
+ assert.calledWithExactly(
9130
+ webex.internal.llm.off,
9131
+ LOCUS_LLM_EVENT,
9132
+ meeting.processLocusLLMEvent
9133
+ );
9134
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9135
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9136
+ assert.isUndefined(meeting.mercuryOnlineHandler);
9137
+ assert.isUndefined(meeting.mercuryOfflineHandler);
9138
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9139
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9140
+ });
9141
+
9142
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
9143
+ const onlineHandler = meeting.mercuryOnlineHandler;
9144
+ const offlineHandler = meeting.mercuryOfflineHandler;
9145
+ meeting.meetingRequest.endMeetingForAll = sinon
9146
+ .stub()
9147
+ .returns(Promise.reject(new Error('end failed')));
9148
+
9149
+ await meeting.endMeetingForAll().catch(() => {});
9150
+
9151
+ assert.calledWithExactly(
9152
+ webex.internal.llm.off,
9153
+ 'event:relay.event',
9154
+ meeting.processRelayEvent
9155
+ );
9156
+ assert.calledWithExactly(
9157
+ webex.internal.llm.off,
9158
+ LOCUS_LLM_EVENT,
9159
+ meeting.processLocusLLMEvent
9160
+ );
9161
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9162
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9163
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9164
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9165
+ });
8483
9166
  });
8484
9167
 
8485
9168
  describe('#moveTo', () => {
@@ -10416,14 +11099,24 @@ describe('plugin-meetings', () => {
10416
11099
  );
10417
11100
  done();
10418
11101
  });
10419
- it('listens to the self admitted guest event', (done) => {
11102
+ it('listens to the self admitted guest event without blocking on token prefetch', async () => {
10420
11103
  meeting.stopKeepAlive = sinon.stub();
10421
11104
  meeting.updateLLMConnection = sinon.stub();
11105
+ let resolvePrefetch;
11106
+
11107
+ meeting.ensureDefaultDatachannelTokenAfterAdmit = sinon
11108
+ .stub()
11109
+ .returns(new Promise((resolve) => {
11110
+ resolvePrefetch = resolve;
11111
+ }));
10422
11112
  meeting.rtcMetrics = {
10423
11113
  sendNextMetrics: sinon.stub(),
10424
11114
  };
11115
+
10425
11116
  meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
11117
+
10426
11118
  assert.calledOnceWithExactly(meeting.stopKeepAlive);
11119
+ assert.calledOnceWithExactly(meeting.ensureDefaultDatachannelTokenAfterAdmit);
10427
11120
  assert.calledThrice(TriggerProxy.trigger);
10428
11121
  assert.calledWith(
10429
11122
  TriggerProxy.trigger,
@@ -10442,7 +11135,11 @@ describe('plugin-meetings', () => {
10442
11135
  correlation_id: meeting.correlationId,
10443
11136
  }
10444
11137
  );
10445
- done();
11138
+
11139
+ resolvePrefetch(false);
11140
+ await Promise.resolve();
11141
+
11142
+ assert.calledOnce(meeting.updateLLMConnection);
10446
11143
  });
10447
11144
 
10448
11145
  it('listens to the breakouts changed event', () => {
@@ -10956,6 +11653,92 @@ describe('plugin-meetings', () => {
10956
11653
  );
10957
11654
  });
10958
11655
 
11656
+ const recordingTestCases = [
11657
+ {
11658
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
11659
+ state: RECORDING_STATE.RECORDING,
11660
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11661
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11662
+ },
11663
+ {
11664
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
11665
+ state: RECORDING_STATE.IDLE,
11666
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
11667
+ expectedRecordingState: RECORDING_STATE.IDLE,
11668
+ },
11669
+ {
11670
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
11671
+ state: RECORDING_STATE.PAUSED,
11672
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
11673
+ expectedRecordingState: RECORDING_STATE.PAUSED,
11674
+ },
11675
+ {
11676
+ description:
11677
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
11678
+ state: RECORDING_STATE.RESUMED,
11679
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
11680
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11681
+ },
11682
+ ];
11683
+
11684
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
11685
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
11686
+ const modifiedBy = 'user-id-123';
11687
+ const lastModified = '2026-01-01T00:00:00Z';
11688
+
11689
+ await meeting.locusInfo.emitScoped(
11690
+ {function: 'test', file: 'test'},
11691
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11692
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
11693
+ );
11694
+
11695
+ assert.deepEqual(meeting.recording, {
11696
+ state: expectedRecordingState,
11697
+ modifiedBy,
11698
+ lastModified,
11699
+ modifiedByServiceAppName: undefined,
11700
+ modifiedByServiceAppId: undefined,
11701
+ });
11702
+
11703
+ assert.calledWith(
11704
+ TriggerProxy.trigger,
11705
+ meeting,
11706
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11707
+ expectedEvent,
11708
+ meeting.recording
11709
+ );
11710
+ });
11711
+ });
11712
+
11713
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
11714
+ const modifiedBy = 'user-id-123';
11715
+ const lastModified = '2026-01-01T00:00:00Z';
11716
+ const modifiedByServiceAppName = 'My Bot';
11717
+ const modifiedByServiceAppId = 'app-id-123';
11718
+
11719
+ await meeting.locusInfo.emitScoped(
11720
+ {function: 'test', file: 'test'},
11721
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11722
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
11723
+ );
11724
+
11725
+ assert.deepEqual(meeting.recording, {
11726
+ state: RECORDING_STATE.RECORDING,
11727
+ modifiedBy,
11728
+ lastModified,
11729
+ modifiedByServiceAppName,
11730
+ modifiedByServiceAppId,
11731
+ });
11732
+
11733
+ assert.calledWith(
11734
+ TriggerProxy.trigger,
11735
+ meeting,
11736
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11737
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11738
+ meeting.recording
11739
+ );
11740
+ });
11741
+
10959
11742
  it('listens to the locus interpretation update event', () => {
10960
11743
  const interpretation = {
10961
11744
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11009,6 +11792,7 @@ describe('plugin-meetings', () => {
11009
11792
  meeting.annotation.locusUrlUpdate = sinon.stub();
11010
11793
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11011
11794
  meeting.webinar.locusUrlUpdate = sinon.stub();
11795
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11012
11796
 
11013
11797
  meeting.locusInfo.emit(
11014
11798
  {function: 'test', file: 'test'},
@@ -11023,6 +11807,7 @@ describe('plugin-meetings', () => {
11023
11807
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11024
11808
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11025
11809
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
11810
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11026
11811
  assert.equal(meeting.locusUrl, newLocusUrl);
11027
11812
  assert(meeting.locusId, '12345');
11028
11813
 
@@ -11338,6 +12123,109 @@ describe('plugin-meetings', () => {
11338
12123
  });
11339
12124
  });
11340
12125
 
12126
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
12127
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
12128
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12129
+
12130
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
12131
+ meeting.destination = {info: {topic: 'old'}};
12132
+
12133
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12134
+
12135
+ assert.equal(meeting.destination, syncedLocus);
12136
+ });
12137
+
12138
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
12139
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12140
+ const originalDestination = {destination: 'original-destination'};
12141
+
12142
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
12143
+ meeting.destination = originalDestination;
12144
+
12145
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
12146
+
12147
+ assert.equal(meeting.destination, originalDestination);
12148
+ });
12149
+
12150
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
12151
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12152
+
12153
+ meeting.meetingInfo = {};
12154
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12155
+
12156
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12157
+
12158
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
12159
+ });
12160
+
12161
+ it('does not fetch meeting info when destination has no info', () => {
12162
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12163
+
12164
+ meeting.meetingInfo = {};
12165
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
12166
+
12167
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12168
+
12169
+ assert.notCalled(fetchMeetingInfoStub);
12170
+ });
12171
+
12172
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
12173
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12174
+
12175
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
12176
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12177
+
12178
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12179
+
12180
+ assert.notCalled(fetchMeetingInfoStub);
12181
+ });
12182
+
12183
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
12184
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12185
+
12186
+ meeting.meetingInfo = {};
12187
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12188
+ meeting.fetchMeetingInfoTimeoutId = 42;
12189
+
12190
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12191
+
12192
+ assert.notCalled(fetchMeetingInfoStub);
12193
+ });
12194
+
12195
+ ['CALL', 'SIP_BRIDGE', 'SPACE_SHARE'].forEach((fullStateType) => {
12196
+ it(`does not fetch meeting info when destination is a 1:1 call (fullState.type ${fullStateType})`, () => {
12197
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12198
+
12199
+ meeting.meetingInfo = {};
12200
+ meeting.destination = {
12201
+ url: 'https://locus.example.com/locus/123',
12202
+ info: {topic: 'x'},
12203
+ };
12204
+
12205
+ meeting.finalizeMeetingAfterInitialLocusSetup({fullState: {type: fullStateType}});
12206
+
12207
+ assert.notCalled(fetchMeetingInfoStub);
12208
+ });
12209
+ });
12210
+
12211
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
12212
+ const error = new Error('fetch failed');
12213
+
12214
+ meeting.meetingInfo = {};
12215
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12216
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
12217
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
12218
+
12219
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
12220
+
12221
+ assert.calledOnce(loggerInfoStub);
12222
+ assert.match(
12223
+ loggerInfoStub.firstCall.args[0],
12224
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
12225
+ );
12226
+ });
12227
+ });
12228
+
11341
12229
  describe('#emailInput', () => {
11342
12230
  it('should set the email input', () => {
11343
12231
  assert.notOk(meeting.emailInput);
@@ -11940,6 +12828,7 @@ describe('plugin-meetings', () => {
11940
12828
  let showAutoEndMeetingWarningSpy;
11941
12829
  let canAttendeeRequestAiAssistantEnabledSpy;
11942
12830
  let attendeeRequestAiAssistantDeclinedAllSpy;
12831
+ let isAnonymizeDisplayNamesEnabledSpy;
11943
12832
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11944
12833
 
11945
12834
  beforeEach(() => {
@@ -11988,6 +12877,10 @@ describe('plugin-meetings', () => {
11988
12877
  MeetingUtil,
11989
12878
  'attendeeRequestAiAssistantDeclinedAll'
11990
12879
  );
12880
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
12881
+ MeetingUtil,
12882
+ 'isAnonymizeDisplayNamesEnabled'
12883
+ );
11991
12884
  });
11992
12885
 
11993
12886
  afterEach(() => {
@@ -11996,6 +12889,7 @@ describe('plugin-meetings', () => {
11996
12889
  showAutoEndMeetingWarningSpy.restore();
11997
12890
  canAttendeeRequestAiAssistantEnabledSpy.restore();
11998
12891
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
12892
+ isAnonymizeDisplayNamesEnabledSpy.restore();
11999
12893
  });
12000
12894
 
12001
12895
  forEach(
@@ -12553,6 +13447,7 @@ describe('plugin-meetings', () => {
12553
13447
  meeting.roles
12554
13448
  );
12555
13449
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13450
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12556
13451
 
12557
13452
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12558
13453
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -13124,7 +14019,9 @@ describe('plugin-meetings', () => {
13124
14019
  info: {datachannelUrl: 'a datachannel url'},
13125
14020
  };
13126
14021
 
13127
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
14022
+ webex.internal.llm.getDatachannelToken
14023
+ .withArgs('llm-default-session')
14024
+ .returns('token-123');
13128
14025
 
13129
14026
  await meeting.updateLLMConnection();
13130
14027
 
@@ -13178,6 +14075,131 @@ describe('plugin-meetings', () => {
13178
14075
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13179
14076
  });
13180
14077
 
14078
+ describe('ownership tag', () => {
14079
+ beforeEach(() => {
14080
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
14081
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
14082
+ // real LLM singleton behavior so the finally-block release in
14083
+ // cleanupLLMConneciton is reflected in subsequent reads.
14084
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
14085
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
14086
+ webex.internal.llm.getOwnerMeetingId.returns(id);
14087
+ });
14088
+ });
14089
+
14090
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
14091
+ meeting.joinedWith = {state: 'JOINED'};
14092
+ webex.internal.llm.isConnected.returns(true);
14093
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14094
+ // Locus/datachannel URL mismatch is the *normal* case when
14095
+ // another meeting owns the live socket -- each meeting has its
14096
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
14097
+ // because doing so would tear down the owning meeting's healthy
14098
+ // LLM socket and break its data channel.
14099
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
14100
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
14101
+ meeting.locusInfo = {
14102
+ url: 'a different url',
14103
+ info: {datachannelUrl: 'a different datachannel url'},
14104
+ self: {},
14105
+ };
14106
+
14107
+ const result = await meeting.updateLLMConnection();
14108
+
14109
+ assert.equal(result, undefined);
14110
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14111
+ assert.notCalled(webex.internal.llm.registerAndConnect);
14112
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
14113
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
14114
+ });
14115
+
14116
+
14117
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
14118
+ meeting.joinedWith = {state: 'JOINED'};
14119
+ webex.internal.llm.isConnected.returns(true);
14120
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14121
+ webex.internal.llm.getLocusUrl.returns('a url');
14122
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14123
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
14124
+ meeting.locusInfo = {
14125
+ url: 'a different url',
14126
+ info: {datachannelUrl: 'a datachannel url'},
14127
+ self: {},
14128
+ };
14129
+
14130
+ try {
14131
+ await meeting.updateLLMConnection();
14132
+ } catch (e) {
14133
+ /* updateLLMConnection may reject when cleanup throws */
14134
+ }
14135
+
14136
+ // The owner-eligible finally branch must release the tag so a
14137
+ // subsequent reconnect attempt from any meeting is not blocked.
14138
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
14139
+ });
14140
+
14141
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
14142
+ meeting.joinedWith = {state: 'JOINED'};
14143
+ webex.internal.llm.isConnected.returns(true);
14144
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14145
+ webex.internal.llm.getLocusUrl.returns('a url');
14146
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
14147
+ meeting.locusInfo = {
14148
+ url: 'a different url',
14149
+ info: {datachannelUrl: 'a datachannel url'},
14150
+ self: {},
14151
+ };
14152
+
14153
+ await meeting.updateLLMConnection();
14154
+
14155
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14156
+ code: 3050,
14157
+ reason: 'done (permanent)',
14158
+ });
14159
+ assert.calledWithExactly(
14160
+ webex.internal.llm.registerAndConnect,
14161
+ 'a different url',
14162
+ 'a datachannel url',
14163
+ undefined
14164
+ );
14165
+ // setOwnerMeetingId is called twice: first with undefined in
14166
+ // cleanupLLMConneciton's finally block (so a failed disconnect
14167
+ // cannot leave a stale owner), then with this meeting's id
14168
+ // after registerAndConnect resolves.
14169
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
14170
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
14171
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
14172
+ });
14173
+
14174
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
14175
+ meeting.joinedWith = {state: 'JOINED'};
14176
+ webex.internal.llm.isConnected.returns(false);
14177
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14178
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14179
+
14180
+ await meeting.updateLLMConnection();
14181
+
14182
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14183
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14184
+ });
14185
+
14186
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
14187
+ // Defensive path: if the LLM reports not-connected but an old
14188
+ // ownerId is still present (e.g. race before a successful
14189
+ // connections.delete), this meeting can still claim a fresh
14190
+ // connection.
14191
+ meeting.joinedWith = {state: 'JOINED'};
14192
+ webex.internal.llm.isConnected.returns(false);
14193
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
14194
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14195
+
14196
+ await meeting.updateLLMConnection();
14197
+
14198
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14199
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14200
+ });
14201
+ });
14202
+
13181
14203
  describe('#clearMeetingData', () => {
13182
14204
  beforeEach(() => {
13183
14205
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13209,10 +14231,13 @@ describe('plugin-meetings', () => {
13209
14231
  meeting.processLocusLLMEvent
13210
14232
  );
13211
14233
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13212
- assert.calledOnce(meeting.stopTranscription);
13213
- assert.isUndefined(meeting.transcription);
13214
14234
  assert.calledOnce(meeting.clearDataChannelToken);
13215
- assert.calledOnce(meeting.annotation.deregisterEvents);
14235
+ // stopTranscription and annotation.deregisterEvents are not
14236
+ // called here: they run in stopListeningForMeetingEvents()
14237
+ // before /leave to avoid double-emitting
14238
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
14239
+ assert.notCalled(meeting.stopTranscription);
14240
+ assert.notCalled(meeting.annotation.deregisterEvents);
13216
14241
  });
13217
14242
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13218
14243
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13231,19 +14256,67 @@ describe('plugin-meetings', () => {
13231
14256
  meeting.processLocusLLMEvent
13232
14257
  );
13233
14258
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13234
- assert.calledOnce(meeting.stopTranscription);
13235
- assert.isUndefined(meeting.transcription);
13236
14259
  assert.calledOnce(meeting.clearDataChannelToken);
13237
- assert.calledOnce(meeting.annotation.deregisterEvents);
14260
+ assert.notCalled(meeting.stopTranscription);
14261
+ assert.notCalled(meeting.annotation.deregisterEvents);
13238
14262
  });
13239
- it('always calls stopTranscription even when transcription is undefined', async () => {
13240
- meeting.transcription = undefined;
13241
14263
 
13242
- await meeting.clearMeetingData();
14264
+ describe('ownership tag', () => {
14265
+ beforeEach(() => {
14266
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
14267
+ });
13243
14268
 
13244
- assert.calledOnce(meeting.stopTranscription);
13245
- assert.isUndefined(meeting.transcription);
13246
- assert.calledOnce(meeting.clearDataChannelToken);
14269
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
14270
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14271
+
14272
+ await meeting.clearMeetingData();
14273
+
14274
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14275
+ // Shared data-channel auth tokens belong to the owner meeting's
14276
+ // live LLM session and must not be wiped by a non-owner
14277
+ // teardown, otherwise the owner's next reconnect would lose
14278
+ // its Data-Channel-Auth-Token.
14279
+ assert.notCalled(meeting.clearDataChannelToken);
14280
+ // Listeners owned by *this* Meeting instance must still be
14281
+ // removed so a leaving subordinate meeting stops receiving
14282
+ // relay/locus events from the shared singleton.
14283
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14284
+ assert.calledWithExactly(
14285
+ webex.internal.llm.off,
14286
+ 'event:relay.event',
14287
+ meeting.processRelayEvent
14288
+ );
14289
+ assert.calledWithExactly(
14290
+ webex.internal.llm.off,
14291
+ 'event:locus.state_message',
14292
+ meeting.processLocusLLMEvent
14293
+ );
14294
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14295
+ });
14296
+
14297
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14298
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14299
+
14300
+ await meeting.clearMeetingData();
14301
+
14302
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14303
+ code: 3050,
14304
+ reason: 'done (permanent)',
14305
+ });
14306
+ assert.calledOnce(meeting.clearDataChannelToken);
14307
+ });
14308
+
14309
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14310
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14311
+
14312
+ await meeting.clearMeetingData();
14313
+
14314
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14315
+ code: 3050,
14316
+ reason: 'done (permanent)',
14317
+ });
14318
+ assert.calledOnce(meeting.clearDataChannelToken);
14319
+ });
13247
14320
  });
13248
14321
  });
13249
14322
  });
@@ -14997,16 +16070,25 @@ describe('plugin-meetings', () => {
14997
16070
  assert.notCalled(meeting.meetingRequest.sendReaction);
14998
16071
  });
14999
16072
 
15000
- it('should fail sending a reaction if reactionType is invalid ', async () => {
16073
+ it('should send a custom reaction type not in the known list', async () => {
15001
16074
  meeting.locusInfo.controls = {reactions: {reactionChannelUrl: 'Fake URL'}};
15002
16075
 
15003
- await assert.isRejected(
15004
- meeting.sendReaction('invalid_reaction', 'light'),
15005
- Error,
15006
- 'invalid_reaction is not a valid reaction.'
15007
- );
16076
+ const reactionPromise = meeting.sendReaction('custom_reaction', 'light');
15008
16077
 
15009
- assert.notCalled(meeting.meetingRequest.sendReaction);
16078
+ assert.exists(reactionPromise.then);
16079
+ await reactionPromise;
16080
+ assert.calledOnceWithExactly(meeting.meetingRequest.sendReaction, {
16081
+ reactionChannelUrl: 'Fake URL',
16082
+ reaction: {
16083
+ type: 'custom_reaction',
16084
+ tone: {
16085
+ type: 'light_skin_tone',
16086
+ codepoints: '1F3FB',
16087
+ shortcodes: ':skin-tone-2:',
16088
+ },
16089
+ },
16090
+ participantId: meeting.members.selfId,
16091
+ });
15010
16092
  });
15011
16093
 
15012
16094
  it('should send a reaction with default skin tone if provided skinToneType is invalid ', async () => {
@@ -16030,4 +17112,4 @@ describe('plugin-meetings', () => {
16030
17112
  assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
16031
17113
  });
16032
17114
  });
16033
- });
17115
+ });