@webex/plugin-meetings 3.12.0-next.5 → 3.12.0-next.51

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 (136) 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 +6 -2
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +1 -1
  7. package/dist/config.js +1 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/constants.js +6 -3
  10. package/dist/constants.js.map +1 -1
  11. package/dist/controls-options-manager/constants.js +11 -1
  12. package/dist/controls-options-manager/constants.js.map +1 -1
  13. package/dist/controls-options-manager/index.js +38 -24
  14. package/dist/controls-options-manager/index.js.map +1 -1
  15. package/dist/controls-options-manager/util.js +91 -0
  16. package/dist/controls-options-manager/util.js.map +1 -1
  17. package/dist/hashTree/constants.js +10 -1
  18. package/dist/hashTree/constants.js.map +1 -1
  19. package/dist/hashTree/hashTreeParser.js +646 -371
  20. package/dist/hashTree/hashTreeParser.js.map +1 -1
  21. package/dist/hashTree/utils.js +22 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/locusRetry.js +23 -8
  26. package/dist/interceptors/locusRetry.js.map +1 -1
  27. package/dist/interpretation/index.js +10 -1
  28. package/dist/interpretation/index.js.map +1 -1
  29. package/dist/interpretation/siLanguage.js +1 -1
  30. package/dist/locus-info/controlsUtils.js +4 -1
  31. package/dist/locus-info/controlsUtils.js.map +1 -1
  32. package/dist/locus-info/index.js +289 -86
  33. package/dist/locus-info/index.js.map +1 -1
  34. package/dist/locus-info/types.js +19 -0
  35. package/dist/locus-info/types.js.map +1 -1
  36. package/dist/media/properties.js +1 -0
  37. package/dist/media/properties.js.map +1 -1
  38. package/dist/meeting/in-meeting-actions.js +3 -1
  39. package/dist/meeting/in-meeting-actions.js.map +1 -1
  40. package/dist/meeting/index.js +842 -521
  41. package/dist/meeting/index.js.map +1 -1
  42. package/dist/meeting/util.js +19 -2
  43. package/dist/meeting/util.js.map +1 -1
  44. package/dist/meetings/index.js +205 -77
  45. package/dist/meetings/index.js.map +1 -1
  46. package/dist/meetings/meetings.types.js +6 -1
  47. package/dist/meetings/meetings.types.js.map +1 -1
  48. package/dist/meetings/request.js +39 -0
  49. package/dist/meetings/request.js.map +1 -1
  50. package/dist/meetings/util.js +67 -5
  51. package/dist/meetings/util.js.map +1 -1
  52. package/dist/member/index.js +10 -0
  53. package/dist/member/index.js.map +1 -1
  54. package/dist/member/types.js.map +1 -1
  55. package/dist/member/util.js +3 -0
  56. package/dist/member/util.js.map +1 -1
  57. package/dist/metrics/constants.js +2 -1
  58. package/dist/metrics/constants.js.map +1 -1
  59. package/dist/recording-controller/index.js +1 -3
  60. package/dist/recording-controller/index.js.map +1 -1
  61. package/dist/types/config.d.ts +1 -0
  62. package/dist/types/constants.d.ts +2 -0
  63. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  64. package/dist/types/controls-options-manager/index.d.ts +10 -0
  65. package/dist/types/hashTree/constants.d.ts +1 -0
  66. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  67. package/dist/types/hashTree/utils.d.ts +11 -0
  68. package/dist/types/index.d.ts +2 -0
  69. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  70. package/dist/types/locus-info/index.d.ts +46 -6
  71. package/dist/types/locus-info/types.d.ts +21 -1
  72. package/dist/types/media/properties.d.ts +1 -0
  73. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  74. package/dist/types/meeting/index.d.ts +70 -1
  75. package/dist/types/meeting/util.d.ts +8 -0
  76. package/dist/types/meetings/index.d.ts +20 -2
  77. package/dist/types/meetings/meetings.types.d.ts +15 -0
  78. package/dist/types/meetings/request.d.ts +14 -0
  79. package/dist/types/member/index.d.ts +1 -0
  80. package/dist/types/member/types.d.ts +1 -0
  81. package/dist/types/member/util.d.ts +1 -0
  82. package/dist/types/metrics/constants.d.ts +1 -0
  83. package/dist/webinar/index.js +361 -235
  84. package/dist/webinar/index.js.map +1 -1
  85. package/package.json +22 -22
  86. package/src/aiEnableRequest/index.ts +16 -0
  87. package/src/breakouts/breakout.ts +2 -1
  88. package/src/config.ts +1 -0
  89. package/src/constants.ts +5 -1
  90. package/src/controls-options-manager/constants.ts +14 -1
  91. package/src/controls-options-manager/index.ts +47 -24
  92. package/src/controls-options-manager/util.ts +81 -1
  93. package/src/hashTree/constants.ts +9 -0
  94. package/src/hashTree/hashTreeParser.ts +362 -174
  95. package/src/hashTree/utils.ts +17 -0
  96. package/src/index.ts +5 -0
  97. package/src/interceptors/locusRetry.ts +25 -4
  98. package/src/interpretation/index.ts +25 -8
  99. package/src/locus-info/controlsUtils.ts +3 -1
  100. package/src/locus-info/index.ts +291 -93
  101. package/src/locus-info/types.ts +25 -1
  102. package/src/media/properties.ts +1 -0
  103. package/src/meeting/in-meeting-actions.ts +4 -0
  104. package/src/meeting/index.ts +315 -26
  105. package/src/meeting/util.ts +20 -2
  106. package/src/meetings/index.ts +109 -43
  107. package/src/meetings/meetings.types.ts +19 -0
  108. package/src/meetings/request.ts +43 -0
  109. package/src/meetings/util.ts +80 -1
  110. package/src/member/index.ts +10 -0
  111. package/src/member/types.ts +1 -0
  112. package/src/member/util.ts +3 -0
  113. package/src/metrics/constants.ts +1 -0
  114. package/src/recording-controller/index.ts +1 -2
  115. package/src/webinar/index.ts +162 -21
  116. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  117. package/test/unit/spec/breakouts/breakout.ts +7 -3
  118. package/test/unit/spec/controls-options-manager/index.js +140 -29
  119. package/test/unit/spec/controls-options-manager/util.js +165 -0
  120. package/test/unit/spec/hashTree/hashTreeParser.ts +1341 -140
  121. package/test/unit/spec/hashTree/utils.ts +88 -1
  122. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  123. package/test/unit/spec/interpretation/index.ts +26 -4
  124. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  125. package/test/unit/spec/locus-info/index.js +475 -81
  126. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  127. package/test/unit/spec/meeting/index.js +836 -41
  128. package/test/unit/spec/meeting/muteState.js +3 -0
  129. package/test/unit/spec/meeting/utils.js +33 -0
  130. package/test/unit/spec/meetings/index.js +309 -10
  131. package/test/unit/spec/meetings/request.js +141 -0
  132. package/test/unit/spec/meetings/utils.js +161 -0
  133. package/test/unit/spec/member/index.js +7 -0
  134. package/test/unit/spec/member/util.js +24 -0
  135. package/test/unit/spec/recording-controller/index.js +9 -8
  136. package/test/unit/spec/webinar/index.ts +141 -16
@@ -34,6 +34,8 @@ import {
34
34
  ONLINE,
35
35
  OFFLINE,
36
36
  ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT,
37
+ LOCUS_LLM_EVENT,
38
+ RECORDING_STATE,
37
39
  } from '@webex/plugin-meetings/src/constants';
38
40
  import {
39
41
  ConnectionState,
@@ -1982,11 +1984,12 @@ describe('plugin-meetings', () => {
1982
1984
  describe('#handleLLMOnline', () => {
1983
1985
  beforeEach(() => {
1984
1986
  webex.internal.llm.off = sinon.stub();
1987
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
1988
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
1985
1989
  });
1986
1990
 
1987
- it('turns off llm online, emits transcription connected events', () => {
1991
+ it('emits transcription connected events', () => {
1988
1992
  meeting.handleLLMOnline();
1989
- assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
1990
1993
  assert.calledWith(
1991
1994
  TriggerProxy.trigger,
1992
1995
  sinon.match.instanceOf(Meeting),
@@ -1997,6 +2000,24 @@ describe('plugin-meetings', () => {
1997
2000
  EVENT_TRIGGERS.MEETING_TRANSCRIPTION_CONNECTED
1998
2001
  );
1999
2002
  });
2003
+
2004
+ it('restores transcription subscription when caption intent is enabled', () => {
2005
+ webex.internal.voicea.getIsCaptionBoxOn.returns(true);
2006
+
2007
+ meeting.handleLLMOnline();
2008
+
2009
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, {
2010
+ subscribe: ['transcription'],
2011
+ });
2012
+ });
2013
+
2014
+ it('does not restore transcription subscription when caption intent is disabled', () => {
2015
+ webex.internal.voicea.getIsCaptionBoxOn.returns(false);
2016
+
2017
+ meeting.handleLLMOnline();
2018
+
2019
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2020
+ });
2000
2021
  });
2001
2022
 
2002
2023
  describe('#join', () => {
@@ -2016,6 +2037,7 @@ describe('plugin-meetings', () => {
2016
2037
  it('should have #join', () => {
2017
2038
  assert.exists(meeting.join);
2018
2039
  });
2040
+
2019
2041
  beforeEach(() => {
2020
2042
  setCorrelationIdSpy = sinon.spy(meeting, 'setCorrelationId');
2021
2043
  meeting.setLocus = sinon.stub().returns(true);
@@ -2169,7 +2191,6 @@ describe('plugin-meetings', () => {
2169
2191
  await meeting.join().catch(() => {
2170
2192
  assert.calledOnce(MeetingUtil.joinMeeting);
2171
2193
 
2172
- // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
2173
2194
  assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
2174
2195
  assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
2175
2196
  name: 'client.call.initiated',
@@ -2201,6 +2222,7 @@ describe('plugin-meetings', () => {
2201
2222
  });
2202
2223
  });
2203
2224
  });
2225
+
2204
2226
  describe('lmm, transcription & permissionTokenRefresh decoupling', () => {
2205
2227
  beforeEach(() => {
2206
2228
  sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult));
@@ -2271,7 +2293,6 @@ describe('plugin-meetings', () => {
2271
2293
  const locusInfoParseStub = sinon.stub(meeting.locusInfo, 'parse');
2272
2294
  sinon.stub(meeting, 'isJoined').returns(true);
2273
2295
 
2274
- // Set up llm.on stub to capture the registered listener when updateLLMConnection is called
2275
2296
  let locusLLMEventListener;
2276
2297
  meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => {
2277
2298
  if (eventName === 'event:locus.state_message') {
@@ -2280,16 +2301,12 @@ describe('plugin-meetings', () => {
2280
2301
  });
2281
2302
  meeting.webex.internal.llm.off = sinon.stub();
2282
2303
 
2283
- // we need the real meeting.updateLLMConnection not the mock
2284
2304
  meeting.updateLLMConnection.restore();
2285
2305
 
2286
- // Call updateLLMConnection to register the listener
2287
2306
  await meeting.updateLLMConnection();
2288
2307
 
2289
- // Verify the listener was registered and we captured it
2290
2308
  assert.isDefined(locusLLMEventListener, 'LLM event listener should be registered');
2291
2309
 
2292
- // Now trigger the event
2293
2310
  const eventData = {
2294
2311
  eventType: 'locus.state_message',
2295
2312
  stateElementsMessage: {
@@ -2309,13 +2326,10 @@ describe('plugin-meetings', () => {
2309
2326
  sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true);
2310
2327
  sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({});
2311
2328
 
2312
- // Restore the real updateLLMConnection
2313
2329
  meeting.updateLLMConnection.restore();
2314
2330
 
2315
- // Call updateLLMConnection to start the timer
2316
2331
  await meeting.updateLLMConnection();
2317
2332
 
2318
- // Fast forward time by 3 minutes
2319
2333
  fakeClock.tick(3 * 60 * 1000);
2320
2334
 
2321
2335
  assert.calledWith(
@@ -2340,18 +2354,14 @@ describe('plugin-meetings', () => {
2340
2354
  .stub(meeting.webex.internal.llm, 'getDatachannelUrl')
2341
2355
  .returns('https://datachannel1.example.com');
2342
2356
 
2343
- // Restore the real updateLLMConnection
2344
2357
  meeting.updateLLMConnection.restore();
2345
2358
 
2346
- // First, connect LLM and start the timer
2347
2359
  isJoinedStub.returns(true);
2348
2360
  meeting.webex.internal.llm.isConnected.returns(false);
2349
2361
  await meeting.updateLLMConnection();
2350
2362
 
2351
- // Verify timer was started
2352
2363
  assert.exists(meeting.llmHealthCheckTimer);
2353
2364
 
2354
- // Now simulate that we're no longer joined
2355
2365
  isJoinedStub.returns(false);
2356
2366
  meeting.webex.internal.llm.isConnected.returns(true);
2357
2367
 
@@ -2359,10 +2369,8 @@ describe('plugin-meetings', () => {
2359
2369
 
2360
2370
  assert.calledOnce(meeting.webex.internal.llm.disconnectLLM);
2361
2371
 
2362
- // Verify the timer was cleared (should be undefined)
2363
2372
  assert.isUndefined(meeting.llmHealthCheckTimer);
2364
2373
 
2365
- // Fast forward time to ensure no metric is sent
2366
2374
  Metrics.sendBehavioralMetric.resetHistory();
2367
2375
  fakeClock.tick(3 * 60 * 1000);
2368
2376
 
@@ -2397,7 +2405,6 @@ describe('plugin-meetings', () => {
2397
2405
  .stub()
2398
2406
  .rejects(new CaptchaError('bad captcha'));
2399
2407
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2400
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil, 'joinMeetingOptions');
2401
2408
 
2402
2409
  try {
2403
2410
  await meeting.join();
@@ -2411,8 +2418,7 @@ describe('plugin-meetings', () => {
2411
2418
  );
2412
2419
  assert.instanceOf(error, CaptchaError);
2413
2420
  assert.equal(error.message, 'bad captcha');
2414
- // should not get to the end promise chain, which does do the join
2415
- assert.notCalled(joinMeetingOptionsSpy);
2421
+ assert.notCalled(MeetingUtil.joinMeeting);
2416
2422
  }
2417
2423
  });
2418
2424
 
@@ -2421,7 +2427,6 @@ describe('plugin-meetings', () => {
2421
2427
  .stub()
2422
2428
  .rejects(new PasswordError('bad password'));
2423
2429
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2424
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2425
2430
 
2426
2431
  try {
2427
2432
  await meeting.join();
@@ -2435,8 +2440,7 @@ describe('plugin-meetings', () => {
2435
2440
  );
2436
2441
  assert.instanceOf(error, PasswordError);
2437
2442
  assert.equal(error.message, 'bad password');
2438
- // should not get to the end promise chain, which does do the join
2439
- assert.notCalled(joinMeetingOptionsSpy);
2443
+ assert.notCalled(MeetingUtil.joinMeeting);
2440
2444
  }
2441
2445
  });
2442
2446
 
@@ -2445,7 +2449,6 @@ describe('plugin-meetings', () => {
2445
2449
  .stub()
2446
2450
  .rejects(new PermissionError('bad permission'));
2447
2451
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2448
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2449
2452
 
2450
2453
  try {
2451
2454
  await meeting.join();
@@ -2459,14 +2462,14 @@ describe('plugin-meetings', () => {
2459
2462
  );
2460
2463
  assert.instanceOf(error, PermissionError);
2461
2464
  assert.equal(error.message, 'bad permission');
2462
- // should not get to the end promise chain, which does do the join
2463
- assert.notCalled(joinMeetingOptionsSpy);
2465
+ assert.notCalled(MeetingUtil.joinMeeting);
2464
2466
  }
2465
2467
  });
2466
2468
  });
2467
2469
  });
2468
2470
  });
2469
2471
 
2472
+
2470
2473
  describe('#addMedia', () => {
2471
2474
  const muteStateStub = {
2472
2475
  handleClientRequest: sinon.stub().returns(Promise.resolve(true)),
@@ -4533,6 +4536,297 @@ describe('plugin-meetings', () => {
4533
4536
  },
4534
4537
  });
4535
4538
  });
4539
+
4540
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
4541
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
4542
+ const fakeStats = new Map([
4543
+ [
4544
+ 'transport-1',
4545
+ {
4546
+ type: 'transport',
4547
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4548
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4549
+ },
4550
+ ],
4551
+ [
4552
+ 'outbound-rtp-1',
4553
+ {
4554
+ type: 'outbound-rtp',
4555
+ ssrc: 12345,
4556
+ },
4557
+ ],
4558
+ ]);
4559
+
4560
+ statsAnalyzerStub.emit(
4561
+ {file: 'test', function: 'test'},
4562
+ StatsAnalyzerEventNames.STATS_UPDATE,
4563
+ {stats: fakeStats}
4564
+ );
4565
+
4566
+ assert.calledWith(
4567
+ TriggerProxy.trigger,
4568
+ sinon.match.instanceOf(Meeting),
4569
+ {
4570
+ file: 'meeting/index',
4571
+ function: 'setupStatsAnalyzerEventHandlers',
4572
+ },
4573
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4574
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4575
+ );
4576
+
4577
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4578
+ });
4579
+
4580
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
4581
+ const firstStats = new Map([
4582
+ [
4583
+ 'transport-1',
4584
+ {
4585
+ type: 'transport',
4586
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4587
+ },
4588
+ ],
4589
+ ]);
4590
+
4591
+ statsAnalyzerStub.emit(
4592
+ {file: 'test', function: 'test'},
4593
+ StatsAnalyzerEventNames.STATS_UPDATE,
4594
+ {stats: firstStats}
4595
+ );
4596
+
4597
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4598
+
4599
+ const secondStats = new Map([
4600
+ [
4601
+ 'transport-1',
4602
+ {
4603
+ type: 'transport',
4604
+ srtpCipher: 'AEAD_AES_256_GCM',
4605
+ },
4606
+ ],
4607
+ ]);
4608
+
4609
+ TriggerProxy.trigger.resetHistory();
4610
+
4611
+ statsAnalyzerStub.emit(
4612
+ {file: 'test', function: 'test'},
4613
+ StatsAnalyzerEventNames.STATS_UPDATE,
4614
+ {stats: secondStats}
4615
+ );
4616
+
4617
+ assert.calledWith(
4618
+ TriggerProxy.trigger,
4619
+ sinon.match.instanceOf(Meeting),
4620
+ {
4621
+ file: 'meeting/index',
4622
+ function: 'setupStatsAnalyzerEventHandlers',
4623
+ },
4624
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4625
+ {srtpCipher: 'AEAD_AES_256_GCM'}
4626
+ );
4627
+
4628
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
4629
+ });
4630
+
4631
+ it('does not emit event when srtpCipher has not changed', async () => {
4632
+ const firstStats = new Map([
4633
+ [
4634
+ 'transport-1',
4635
+ {
4636
+ type: 'transport',
4637
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4638
+ },
4639
+ ],
4640
+ ]);
4641
+
4642
+ statsAnalyzerStub.emit(
4643
+ {file: 'test', function: 'test'},
4644
+ StatsAnalyzerEventNames.STATS_UPDATE,
4645
+ {stats: firstStats}
4646
+ );
4647
+
4648
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4649
+
4650
+ TriggerProxy.trigger.resetHistory();
4651
+
4652
+ // Emit same cipher again
4653
+ statsAnalyzerStub.emit(
4654
+ {file: 'test', function: 'test'},
4655
+ StatsAnalyzerEventNames.STATS_UPDATE,
4656
+ {stats: firstStats}
4657
+ );
4658
+
4659
+ // Should not trigger event again
4660
+ assert.neverCalledWith(
4661
+ TriggerProxy.trigger,
4662
+ sinon.match.instanceOf(Meeting),
4663
+ sinon.match.any,
4664
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4665
+ sinon.match.any
4666
+ );
4667
+
4668
+ // Cipher should remain the same
4669
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4670
+ });
4671
+
4672
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
4673
+ const fakeStats = new Map([
4674
+ [
4675
+ 'outbound-rtp-1',
4676
+ {
4677
+ type: 'outbound-rtp',
4678
+ ssrc: 12345,
4679
+ },
4680
+ ],
4681
+ [
4682
+ 'inbound-rtp-1',
4683
+ {
4684
+ type: 'inbound-rtp',
4685
+ ssrc: 67890,
4686
+ },
4687
+ ],
4688
+ ]);
4689
+
4690
+ statsAnalyzerStub.emit(
4691
+ {file: 'test', function: 'test'},
4692
+ StatsAnalyzerEventNames.STATS_UPDATE,
4693
+ {stats: fakeStats}
4694
+ );
4695
+
4696
+ assert.neverCalledWith(
4697
+ TriggerProxy.trigger,
4698
+ sinon.match.instanceOf(Meeting),
4699
+ sinon.match.any,
4700
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4701
+ sinon.match.any
4702
+ );
4703
+
4704
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4705
+ });
4706
+
4707
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
4708
+ const fakeStats = new Map([
4709
+ [
4710
+ 'transport-1',
4711
+ {
4712
+ type: 'transport',
4713
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4714
+ // no srtpCipher property
4715
+ },
4716
+ ],
4717
+ ]);
4718
+
4719
+ statsAnalyzerStub.emit(
4720
+ {file: 'test', function: 'test'},
4721
+ StatsAnalyzerEventNames.STATS_UPDATE,
4722
+ {stats: fakeStats}
4723
+ );
4724
+
4725
+ assert.neverCalledWith(
4726
+ TriggerProxy.trigger,
4727
+ sinon.match.instanceOf(Meeting),
4728
+ sinon.match.any,
4729
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4730
+ sinon.match.any
4731
+ );
4732
+
4733
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4734
+ });
4735
+
4736
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
4737
+ const fakeStats = new Map([
4738
+ [
4739
+ 'transport-1',
4740
+ {
4741
+ type: 'transport',
4742
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4743
+ },
4744
+ ],
4745
+ [
4746
+ 'transport-2',
4747
+ {
4748
+ type: 'transport',
4749
+ srtpCipher: 'AEAD_AES_256_GCM',
4750
+ },
4751
+ ],
4752
+ [
4753
+ 'outbound-rtp-1',
4754
+ {
4755
+ type: 'outbound-rtp',
4756
+ ssrc: 12345,
4757
+ },
4758
+ ],
4759
+ ]);
4760
+
4761
+ statsAnalyzerStub.emit(
4762
+ {file: 'test', function: 'test'},
4763
+ StatsAnalyzerEventNames.STATS_UPDATE,
4764
+ {stats: fakeStats}
4765
+ );
4766
+
4767
+ assert.calledWith(
4768
+ TriggerProxy.trigger,
4769
+ sinon.match.instanceOf(Meeting),
4770
+ {
4771
+ file: 'meeting/index',
4772
+ function: 'setupStatsAnalyzerEventHandlers',
4773
+ },
4774
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4775
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4776
+ );
4777
+
4778
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4779
+ });
4780
+
4781
+ it('handles empty stats map without errors', async () => {
4782
+ const emptyStats = new Map();
4783
+
4784
+ statsAnalyzerStub.emit(
4785
+ {file: 'test', function: 'test'},
4786
+ StatsAnalyzerEventNames.STATS_UPDATE,
4787
+ {stats: emptyStats}
4788
+ );
4789
+
4790
+ assert.neverCalledWith(
4791
+ TriggerProxy.trigger,
4792
+ sinon.match.instanceOf(Meeting),
4793
+ sinon.match.any,
4794
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4795
+ sinon.match.any
4796
+ );
4797
+
4798
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4799
+ });
4800
+
4801
+ it('logs cipher change when cipher is updated', async () => {
4802
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
4803
+
4804
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
4805
+
4806
+ const newStats = new Map([
4807
+ [
4808
+ 'transport-1',
4809
+ {
4810
+ type: 'transport',
4811
+ srtpCipher: 'AEAD_AES_256_GCM',
4812
+ },
4813
+ ],
4814
+ ]);
4815
+
4816
+ statsAnalyzerStub.emit(
4817
+ {file: 'test', function: 'test'},
4818
+ StatsAnalyzerEventNames.STATS_UPDATE,
4819
+ {stats: newStats}
4820
+ );
4821
+
4822
+ assert.calledWithMatch(
4823
+ loggerSpy,
4824
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
4825
+ );
4826
+
4827
+ loggerSpy.restore();
4828
+ });
4829
+ });
4536
4830
  });
4537
4831
 
4538
4832
  describe('handles StatsMonitor events', () => {
@@ -6428,6 +6722,9 @@ describe('plugin-meetings', () => {
6428
6722
 
6429
6723
  meeting.annotation.deregisterEvents = sinon.stub();
6430
6724
  webex.internal.llm.off = sinon.stub();
6725
+ webex.internal.mercury.off = sinon.stub();
6726
+ meeting.mercuryOnlineHandler = sinon.stub();
6727
+ meeting.mercuryOfflineHandler = sinon.stub();
6431
6728
 
6432
6729
  // A meeting needs to be joined to leave
6433
6730
  meeting.meetingState = 'ACTIVE';
@@ -6451,6 +6748,67 @@ describe('plugin-meetings', () => {
6451
6748
  assert.calledOnce(meeting.clearMeetingData);
6452
6749
  });
6453
6750
 
6751
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
6752
+ const onlineHandler = meeting.mercuryOnlineHandler;
6753
+ const offlineHandler = meeting.mercuryOfflineHandler;
6754
+
6755
+ await meeting.leave();
6756
+
6757
+ // All llm/mercury consumers (direct listeners, voicea transcription,
6758
+ // annotation) must be detached before the /leave request so that
6759
+ // in-flight events do not trigger unnecessary Locus syncs
6760
+ // (per Locus team recommendation).
6761
+ assert.callOrder(
6762
+ webex.internal.llm.off,
6763
+ webex.internal.mercury.off,
6764
+ meeting.stopTranscription,
6765
+ meeting.annotation.deregisterEvents,
6766
+ meeting.meetingRequest.leaveMeeting
6767
+ );
6768
+ assert.calledWithExactly(
6769
+ webex.internal.llm.off,
6770
+ 'event:relay.event',
6771
+ meeting.processRelayEvent
6772
+ );
6773
+ assert.calledWithExactly(
6774
+ webex.internal.llm.off,
6775
+ LOCUS_LLM_EVENT,
6776
+ meeting.processLocusLLMEvent
6777
+ );
6778
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
6779
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
6780
+ assert.isUndefined(meeting.mercuryOnlineHandler);
6781
+ assert.isUndefined(meeting.mercuryOfflineHandler);
6782
+ assert.calledOnceWithExactly(meeting.stopTranscription);
6783
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
6784
+ assert.isUndefined(meeting.transcription);
6785
+ });
6786
+
6787
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
6788
+ const onlineHandler = meeting.mercuryOnlineHandler;
6789
+ const offlineHandler = meeting.mercuryOfflineHandler;
6790
+ meeting.meetingRequest.leaveMeeting = sinon
6791
+ .stub()
6792
+ .returns(Promise.reject(new Error('leave failed')));
6793
+
6794
+ await meeting.leave().catch(() => {});
6795
+
6796
+ assert.calledWithExactly(
6797
+ webex.internal.llm.off,
6798
+ 'event:relay.event',
6799
+ meeting.processRelayEvent
6800
+ );
6801
+ assert.calledWithExactly(
6802
+ webex.internal.llm.off,
6803
+ LOCUS_LLM_EVENT,
6804
+ meeting.processLocusLLMEvent
6805
+ );
6806
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
6807
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
6808
+ assert.calledOnceWithExactly(meeting.stopTranscription);
6809
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
6810
+ });
6811
+
6454
6812
  it('should reset call diagnostic latencies correctly', async () => {
6455
6813
  const leave = meeting.leave();
6456
6814
 
@@ -8458,6 +8816,9 @@ describe('plugin-meetings', () => {
8458
8816
 
8459
8817
  meeting.annotation.deregisterEvents = sinon.stub();
8460
8818
  webex.internal.llm.off = sinon.stub();
8819
+ webex.internal.mercury.off = sinon.stub();
8820
+ meeting.mercuryOnlineHandler = sinon.stub();
8821
+ meeting.mercuryOfflineHandler = sinon.stub();
8461
8822
 
8462
8823
  // A meeting needs to be joined to end
8463
8824
  meeting.meetingState = 'ACTIVE';
@@ -8480,6 +8841,66 @@ describe('plugin-meetings', () => {
8480
8841
  assert.calledOnce(meeting?.unsetPeerConnections);
8481
8842
  assert.calledOnce(meeting?.clearMeetingData);
8482
8843
  });
8844
+
8845
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
8846
+ const onlineHandler = meeting.mercuryOnlineHandler;
8847
+ const offlineHandler = meeting.mercuryOfflineHandler;
8848
+
8849
+ await meeting.endMeetingForAll();
8850
+
8851
+ // All llm/mercury consumers (direct listeners, voicea transcription,
8852
+ // annotation) must be detached before the /end request so that
8853
+ // in-flight events do not trigger unnecessary Locus syncs
8854
+ // (per Locus team recommendation).
8855
+ assert.callOrder(
8856
+ webex.internal.llm.off,
8857
+ webex.internal.mercury.off,
8858
+ meeting.stopTranscription,
8859
+ meeting.annotation.deregisterEvents,
8860
+ meeting.meetingRequest.endMeetingForAll
8861
+ );
8862
+ assert.calledWithExactly(
8863
+ webex.internal.llm.off,
8864
+ 'event:relay.event',
8865
+ meeting.processRelayEvent
8866
+ );
8867
+ assert.calledWithExactly(
8868
+ webex.internal.llm.off,
8869
+ LOCUS_LLM_EVENT,
8870
+ meeting.processLocusLLMEvent
8871
+ );
8872
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
8873
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
8874
+ assert.isUndefined(meeting.mercuryOnlineHandler);
8875
+ assert.isUndefined(meeting.mercuryOfflineHandler);
8876
+ assert.calledOnceWithExactly(meeting.stopTranscription);
8877
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
8878
+ });
8879
+
8880
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
8881
+ const onlineHandler = meeting.mercuryOnlineHandler;
8882
+ const offlineHandler = meeting.mercuryOfflineHandler;
8883
+ meeting.meetingRequest.endMeetingForAll = sinon
8884
+ .stub()
8885
+ .returns(Promise.reject(new Error('end failed')));
8886
+
8887
+ await meeting.endMeetingForAll().catch(() => {});
8888
+
8889
+ assert.calledWithExactly(
8890
+ webex.internal.llm.off,
8891
+ 'event:relay.event',
8892
+ meeting.processRelayEvent
8893
+ );
8894
+ assert.calledWithExactly(
8895
+ webex.internal.llm.off,
8896
+ LOCUS_LLM_EVENT,
8897
+ meeting.processLocusLLMEvent
8898
+ );
8899
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
8900
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
8901
+ assert.calledOnceWithExactly(meeting.stopTranscription);
8902
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
8903
+ });
8483
8904
  });
8484
8905
 
8485
8906
  describe('#moveTo', () => {
@@ -10416,14 +10837,24 @@ describe('plugin-meetings', () => {
10416
10837
  );
10417
10838
  done();
10418
10839
  });
10419
- it('listens to the self admitted guest event', (done) => {
10840
+ it('listens to the self admitted guest event without blocking on token prefetch', async () => {
10420
10841
  meeting.stopKeepAlive = sinon.stub();
10421
10842
  meeting.updateLLMConnection = sinon.stub();
10843
+ let resolvePrefetch;
10844
+
10845
+ meeting.ensureDefaultDatachannelTokenAfterAdmit = sinon
10846
+ .stub()
10847
+ .returns(new Promise((resolve) => {
10848
+ resolvePrefetch = resolve;
10849
+ }));
10422
10850
  meeting.rtcMetrics = {
10423
10851
  sendNextMetrics: sinon.stub(),
10424
10852
  };
10853
+
10425
10854
  meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
10855
+
10426
10856
  assert.calledOnceWithExactly(meeting.stopKeepAlive);
10857
+ assert.calledOnceWithExactly(meeting.ensureDefaultDatachannelTokenAfterAdmit);
10427
10858
  assert.calledThrice(TriggerProxy.trigger);
10428
10859
  assert.calledWith(
10429
10860
  TriggerProxy.trigger,
@@ -10442,7 +10873,11 @@ describe('plugin-meetings', () => {
10442
10873
  correlation_id: meeting.correlationId,
10443
10874
  }
10444
10875
  );
10445
- done();
10876
+
10877
+ resolvePrefetch(false);
10878
+ await Promise.resolve();
10879
+
10880
+ assert.calledOnce(meeting.updateLLMConnection);
10446
10881
  });
10447
10882
 
10448
10883
  it('listens to the breakouts changed event', () => {
@@ -10956,6 +11391,92 @@ describe('plugin-meetings', () => {
10956
11391
  );
10957
11392
  });
10958
11393
 
11394
+ const recordingTestCases = [
11395
+ {
11396
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
11397
+ state: RECORDING_STATE.RECORDING,
11398
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11399
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11400
+ },
11401
+ {
11402
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
11403
+ state: RECORDING_STATE.IDLE,
11404
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
11405
+ expectedRecordingState: RECORDING_STATE.IDLE,
11406
+ },
11407
+ {
11408
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
11409
+ state: RECORDING_STATE.PAUSED,
11410
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
11411
+ expectedRecordingState: RECORDING_STATE.PAUSED,
11412
+ },
11413
+ {
11414
+ description:
11415
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
11416
+ state: RECORDING_STATE.RESUMED,
11417
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
11418
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11419
+ },
11420
+ ];
11421
+
11422
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
11423
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
11424
+ const modifiedBy = 'user-id-123';
11425
+ const lastModified = '2026-01-01T00:00:00Z';
11426
+
11427
+ await meeting.locusInfo.emitScoped(
11428
+ {function: 'test', file: 'test'},
11429
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11430
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
11431
+ );
11432
+
11433
+ assert.deepEqual(meeting.recording, {
11434
+ state: expectedRecordingState,
11435
+ modifiedBy,
11436
+ lastModified,
11437
+ modifiedByServiceAppName: undefined,
11438
+ modifiedByServiceAppId: undefined,
11439
+ });
11440
+
11441
+ assert.calledWith(
11442
+ TriggerProxy.trigger,
11443
+ meeting,
11444
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11445
+ expectedEvent,
11446
+ meeting.recording
11447
+ );
11448
+ });
11449
+ });
11450
+
11451
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
11452
+ const modifiedBy = 'user-id-123';
11453
+ const lastModified = '2026-01-01T00:00:00Z';
11454
+ const modifiedByServiceAppName = 'My Bot';
11455
+ const modifiedByServiceAppId = 'app-id-123';
11456
+
11457
+ await meeting.locusInfo.emitScoped(
11458
+ {function: 'test', file: 'test'},
11459
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11460
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
11461
+ );
11462
+
11463
+ assert.deepEqual(meeting.recording, {
11464
+ state: RECORDING_STATE.RECORDING,
11465
+ modifiedBy,
11466
+ lastModified,
11467
+ modifiedByServiceAppName,
11468
+ modifiedByServiceAppId,
11469
+ });
11470
+
11471
+ assert.calledWith(
11472
+ TriggerProxy.trigger,
11473
+ meeting,
11474
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11475
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11476
+ meeting.recording
11477
+ );
11478
+ });
11479
+
10959
11480
  it('listens to the locus interpretation update event', () => {
10960
11481
  const interpretation = {
10961
11482
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11009,6 +11530,7 @@ describe('plugin-meetings', () => {
11009
11530
  meeting.annotation.locusUrlUpdate = sinon.stub();
11010
11531
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11011
11532
  meeting.webinar.locusUrlUpdate = sinon.stub();
11533
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11012
11534
 
11013
11535
  meeting.locusInfo.emit(
11014
11536
  {function: 'test', file: 'test'},
@@ -11023,6 +11545,7 @@ describe('plugin-meetings', () => {
11023
11545
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11024
11546
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11025
11547
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
11548
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11026
11549
  assert.equal(meeting.locusUrl, newLocusUrl);
11027
11550
  assert(meeting.locusId, '12345');
11028
11551
 
@@ -11338,6 +11861,93 @@ describe('plugin-meetings', () => {
11338
11861
  });
11339
11862
  });
11340
11863
 
11864
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
11865
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
11866
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11867
+
11868
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
11869
+ meeting.destination = {info: {topic: 'old'}};
11870
+
11871
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
11872
+
11873
+ assert.equal(meeting.destination, syncedLocus);
11874
+ });
11875
+
11876
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
11877
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11878
+ const originalDestination = {destination: 'original-destination'};
11879
+
11880
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
11881
+ meeting.destination = originalDestination;
11882
+
11883
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
11884
+
11885
+ assert.equal(meeting.destination, originalDestination);
11886
+ });
11887
+
11888
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
11889
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
11890
+
11891
+ meeting.meetingInfo = {};
11892
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11893
+
11894
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
11895
+
11896
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
11897
+ });
11898
+
11899
+ it('does not fetch meeting info when destination has no info', () => {
11900
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
11901
+
11902
+ meeting.meetingInfo = {};
11903
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
11904
+
11905
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
11906
+
11907
+ assert.notCalled(fetchMeetingInfoStub);
11908
+ });
11909
+
11910
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
11911
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
11912
+
11913
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
11914
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11915
+
11916
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
11917
+
11918
+ assert.notCalled(fetchMeetingInfoStub);
11919
+ });
11920
+
11921
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
11922
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
11923
+
11924
+ meeting.meetingInfo = {};
11925
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11926
+ meeting.fetchMeetingInfoTimeoutId = 42;
11927
+
11928
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
11929
+
11930
+ assert.notCalled(fetchMeetingInfoStub);
11931
+ });
11932
+
11933
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
11934
+ const error = new Error('fetch failed');
11935
+
11936
+ meeting.meetingInfo = {};
11937
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11938
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
11939
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
11940
+
11941
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
11942
+
11943
+ assert.calledOnce(loggerInfoStub);
11944
+ assert.match(
11945
+ loggerInfoStub.firstCall.args[0],
11946
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
11947
+ );
11948
+ });
11949
+ });
11950
+
11341
11951
  describe('#emailInput', () => {
11342
11952
  it('should set the email input', () => {
11343
11953
  assert.notOk(meeting.emailInput);
@@ -11940,6 +12550,7 @@ describe('plugin-meetings', () => {
11940
12550
  let showAutoEndMeetingWarningSpy;
11941
12551
  let canAttendeeRequestAiAssistantEnabledSpy;
11942
12552
  let attendeeRequestAiAssistantDeclinedAllSpy;
12553
+ let isAnonymizeDisplayNamesEnabledSpy;
11943
12554
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11944
12555
 
11945
12556
  beforeEach(() => {
@@ -11988,6 +12599,10 @@ describe('plugin-meetings', () => {
11988
12599
  MeetingUtil,
11989
12600
  'attendeeRequestAiAssistantDeclinedAll'
11990
12601
  );
12602
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
12603
+ MeetingUtil,
12604
+ 'isAnonymizeDisplayNamesEnabled'
12605
+ );
11991
12606
  });
11992
12607
 
11993
12608
  afterEach(() => {
@@ -11996,6 +12611,7 @@ describe('plugin-meetings', () => {
11996
12611
  showAutoEndMeetingWarningSpy.restore();
11997
12612
  canAttendeeRequestAiAssistantEnabledSpy.restore();
11998
12613
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
12614
+ isAnonymizeDisplayNamesEnabledSpy.restore();
11999
12615
  });
12000
12616
 
12001
12617
  forEach(
@@ -12553,6 +13169,7 @@ describe('plugin-meetings', () => {
12553
13169
  meeting.roles
12554
13170
  );
12555
13171
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13172
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12556
13173
 
12557
13174
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12558
13175
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -13124,7 +13741,9 @@ describe('plugin-meetings', () => {
13124
13741
  info: {datachannelUrl: 'a datachannel url'},
13125
13742
  };
13126
13743
 
13127
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
13744
+ webex.internal.llm.getDatachannelToken
13745
+ .withArgs('llm-default-session')
13746
+ .returns('token-123');
13128
13747
 
13129
13748
  await meeting.updateLLMConnection();
13130
13749
 
@@ -13178,6 +13797,131 @@ describe('plugin-meetings', () => {
13178
13797
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13179
13798
  });
13180
13799
 
13800
+ describe('ownership tag', () => {
13801
+ beforeEach(() => {
13802
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
13803
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
13804
+ // real LLM singleton behavior so the finally-block release in
13805
+ // cleanupLLMConneciton is reflected in subsequent reads.
13806
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
13807
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
13808
+ webex.internal.llm.getOwnerMeetingId.returns(id);
13809
+ });
13810
+ });
13811
+
13812
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
13813
+ meeting.joinedWith = {state: 'JOINED'};
13814
+ webex.internal.llm.isConnected.returns(true);
13815
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13816
+ // Locus/datachannel URL mismatch is the *normal* case when
13817
+ // another meeting owns the live socket -- each meeting has its
13818
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
13819
+ // because doing so would tear down the owning meeting's healthy
13820
+ // LLM socket and break its data channel.
13821
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
13822
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
13823
+ meeting.locusInfo = {
13824
+ url: 'a different url',
13825
+ info: {datachannelUrl: 'a different datachannel url'},
13826
+ self: {},
13827
+ };
13828
+
13829
+ const result = await meeting.updateLLMConnection();
13830
+
13831
+ assert.equal(result, undefined);
13832
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13833
+ assert.notCalled(webex.internal.llm.registerAndConnect);
13834
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
13835
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
13836
+ });
13837
+
13838
+
13839
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
13840
+ meeting.joinedWith = {state: 'JOINED'};
13841
+ webex.internal.llm.isConnected.returns(true);
13842
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13843
+ webex.internal.llm.getLocusUrl.returns('a url');
13844
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13845
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13846
+ meeting.locusInfo = {
13847
+ url: 'a different url',
13848
+ info: {datachannelUrl: 'a datachannel url'},
13849
+ self: {},
13850
+ };
13851
+
13852
+ try {
13853
+ await meeting.updateLLMConnection();
13854
+ } catch (e) {
13855
+ /* updateLLMConnection may reject when cleanup throws */
13856
+ }
13857
+
13858
+ // The owner-eligible finally branch must release the tag so a
13859
+ // subsequent reconnect attempt from any meeting is not blocked.
13860
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
13861
+ });
13862
+
13863
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
13864
+ meeting.joinedWith = {state: 'JOINED'};
13865
+ webex.internal.llm.isConnected.returns(true);
13866
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13867
+ webex.internal.llm.getLocusUrl.returns('a url');
13868
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13869
+ meeting.locusInfo = {
13870
+ url: 'a different url',
13871
+ info: {datachannelUrl: 'a datachannel url'},
13872
+ self: {},
13873
+ };
13874
+
13875
+ await meeting.updateLLMConnection();
13876
+
13877
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13878
+ code: 3050,
13879
+ reason: 'done (permanent)',
13880
+ });
13881
+ assert.calledWithExactly(
13882
+ webex.internal.llm.registerAndConnect,
13883
+ 'a different url',
13884
+ 'a datachannel url',
13885
+ undefined
13886
+ );
13887
+ // setOwnerMeetingId is called twice: first with undefined in
13888
+ // cleanupLLMConneciton's finally block (so a failed disconnect
13889
+ // cannot leave a stale owner), then with this meeting's id
13890
+ // after registerAndConnect resolves.
13891
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
13892
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
13893
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
13894
+ });
13895
+
13896
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
13897
+ meeting.joinedWith = {state: 'JOINED'};
13898
+ webex.internal.llm.isConnected.returns(false);
13899
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
13900
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13901
+
13902
+ await meeting.updateLLMConnection();
13903
+
13904
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13905
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13906
+ });
13907
+
13908
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
13909
+ // Defensive path: if the LLM reports not-connected but an old
13910
+ // ownerId is still present (e.g. race before a successful
13911
+ // connections.delete), this meeting can still claim a fresh
13912
+ // connection.
13913
+ meeting.joinedWith = {state: 'JOINED'};
13914
+ webex.internal.llm.isConnected.returns(false);
13915
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
13916
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13917
+
13918
+ await meeting.updateLLMConnection();
13919
+
13920
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13921
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13922
+ });
13923
+ });
13924
+
13181
13925
  describe('#clearMeetingData', () => {
13182
13926
  beforeEach(() => {
13183
13927
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13209,10 +13953,13 @@ describe('plugin-meetings', () => {
13209
13953
  meeting.processLocusLLMEvent
13210
13954
  );
13211
13955
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13212
- assert.calledOnce(meeting.stopTranscription);
13213
- assert.isUndefined(meeting.transcription);
13214
13956
  assert.calledOnce(meeting.clearDataChannelToken);
13215
- assert.calledOnce(meeting.annotation.deregisterEvents);
13957
+ // stopTranscription and annotation.deregisterEvents are not
13958
+ // called here: they run in stopListeningForMeetingEvents()
13959
+ // before /leave to avoid double-emitting
13960
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
13961
+ assert.notCalled(meeting.stopTranscription);
13962
+ assert.notCalled(meeting.annotation.deregisterEvents);
13216
13963
  });
13217
13964
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13218
13965
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13231,19 +13978,67 @@ describe('plugin-meetings', () => {
13231
13978
  meeting.processLocusLLMEvent
13232
13979
  );
13233
13980
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13234
- assert.calledOnce(meeting.stopTranscription);
13235
- assert.isUndefined(meeting.transcription);
13236
13981
  assert.calledOnce(meeting.clearDataChannelToken);
13237
- assert.calledOnce(meeting.annotation.deregisterEvents);
13982
+ assert.notCalled(meeting.stopTranscription);
13983
+ assert.notCalled(meeting.annotation.deregisterEvents);
13238
13984
  });
13239
- it('always calls stopTranscription even when transcription is undefined', async () => {
13240
- meeting.transcription = undefined;
13241
13985
 
13242
- await meeting.clearMeetingData();
13986
+ describe('ownership tag', () => {
13987
+ beforeEach(() => {
13988
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
13989
+ });
13243
13990
 
13244
- assert.calledOnce(meeting.stopTranscription);
13245
- assert.isUndefined(meeting.transcription);
13246
- assert.calledOnce(meeting.clearDataChannelToken);
13991
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
13992
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13993
+
13994
+ await meeting.clearMeetingData();
13995
+
13996
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13997
+ // Shared data-channel auth tokens belong to the owner meeting's
13998
+ // live LLM session and must not be wiped by a non-owner
13999
+ // teardown, otherwise the owner's next reconnect would lose
14000
+ // its Data-Channel-Auth-Token.
14001
+ assert.notCalled(meeting.clearDataChannelToken);
14002
+ // Listeners owned by *this* Meeting instance must still be
14003
+ // removed so a leaving subordinate meeting stops receiving
14004
+ // relay/locus events from the shared singleton.
14005
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14006
+ assert.calledWithExactly(
14007
+ webex.internal.llm.off,
14008
+ 'event:relay.event',
14009
+ meeting.processRelayEvent
14010
+ );
14011
+ assert.calledWithExactly(
14012
+ webex.internal.llm.off,
14013
+ 'event:locus.state_message',
14014
+ meeting.processLocusLLMEvent
14015
+ );
14016
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14017
+ });
14018
+
14019
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14020
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14021
+
14022
+ await meeting.clearMeetingData();
14023
+
14024
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14025
+ code: 3050,
14026
+ reason: 'done (permanent)',
14027
+ });
14028
+ assert.calledOnce(meeting.clearDataChannelToken);
14029
+ });
14030
+
14031
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14032
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14033
+
14034
+ await meeting.clearMeetingData();
14035
+
14036
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14037
+ code: 3050,
14038
+ reason: 'done (permanent)',
14039
+ });
14040
+ assert.calledOnce(meeting.clearDataChannelToken);
14041
+ });
13247
14042
  });
13248
14043
  });
13249
14044
  });