@webex/plugin-meetings 3.12.0-mobius-socket.2 → 3.12.0-mobius-socket.3

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 (145) 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 +3 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +1 -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 +651 -382
  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/properties.js +1 -0
  38. package/dist/media/properties.js.map +1 -1
  39. package/dist/meeting/in-meeting-actions.js +3 -1
  40. package/dist/meeting/in-meeting-actions.js.map +1 -1
  41. package/dist/meeting/index.js +848 -582
  42. package/dist/meeting/index.js.map +1 -1
  43. package/dist/meeting/util.js +19 -2
  44. package/dist/meeting/util.js.map +1 -1
  45. package/dist/meetings/index.js +205 -77
  46. package/dist/meetings/index.js.map +1 -1
  47. package/dist/meetings/meetings.types.js +6 -1
  48. package/dist/meetings/meetings.types.js.map +1 -1
  49. package/dist/meetings/request.js +39 -0
  50. package/dist/meetings/request.js.map +1 -1
  51. package/dist/meetings/util.js +67 -5
  52. package/dist/meetings/util.js.map +1 -1
  53. package/dist/member/index.js +10 -0
  54. package/dist/member/index.js.map +1 -1
  55. package/dist/member/types.js.map +1 -1
  56. package/dist/member/util.js +3 -0
  57. package/dist/member/util.js.map +1 -1
  58. package/dist/metrics/constants.js +4 -1
  59. package/dist/metrics/constants.js.map +1 -1
  60. package/dist/multistream/receiveSlot.js +9 -0
  61. package/dist/multistream/receiveSlot.js.map +1 -1
  62. package/dist/reactions/reactions.type.js.map +1 -1
  63. package/dist/recording-controller/index.js +1 -3
  64. package/dist/recording-controller/index.js.map +1 -1
  65. package/dist/types/config.d.ts +1 -0
  66. package/dist/types/constants.d.ts +2 -0
  67. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  68. package/dist/types/controls-options-manager/index.d.ts +10 -0
  69. package/dist/types/hashTree/constants.d.ts +1 -0
  70. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  71. package/dist/types/hashTree/utils.d.ts +11 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  74. package/dist/types/locus-info/index.d.ts +46 -6
  75. package/dist/types/locus-info/types.d.ts +21 -1
  76. package/dist/types/media/properties.d.ts +1 -0
  77. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  78. package/dist/types/meeting/index.d.ts +65 -1
  79. package/dist/types/meeting/util.d.ts +8 -0
  80. package/dist/types/meetings/index.d.ts +20 -2
  81. package/dist/types/meetings/meetings.types.d.ts +15 -0
  82. package/dist/types/meetings/request.d.ts +14 -0
  83. package/dist/types/member/index.d.ts +1 -0
  84. package/dist/types/member/types.d.ts +1 -0
  85. package/dist/types/member/util.d.ts +1 -0
  86. package/dist/types/metrics/constants.d.ts +3 -0
  87. package/dist/types/reactions/reactions.type.d.ts +3 -0
  88. package/dist/webinar/index.js +68 -17
  89. package/dist/webinar/index.js.map +1 -1
  90. package/package.json +22 -22
  91. package/src/aiEnableRequest/index.ts +16 -0
  92. package/src/breakouts/breakout.ts +3 -1
  93. package/src/breakouts/index.ts +1 -0
  94. package/src/config.ts +1 -0
  95. package/src/constants.ts +5 -1
  96. package/src/controls-options-manager/constants.ts +14 -1
  97. package/src/controls-options-manager/index.ts +47 -24
  98. package/src/controls-options-manager/util.ts +81 -1
  99. package/src/hashTree/constants.ts +9 -0
  100. package/src/hashTree/hashTreeParser.ts +375 -197
  101. package/src/hashTree/utils.ts +17 -0
  102. package/src/index.ts +5 -0
  103. package/src/interceptors/locusRetry.ts +25 -4
  104. package/src/interpretation/index.ts +25 -8
  105. package/src/locus-info/controlsUtils.ts +3 -1
  106. package/src/locus-info/index.ts +291 -97
  107. package/src/locus-info/types.ts +25 -1
  108. package/src/media/properties.ts +1 -0
  109. package/src/meeting/in-meeting-actions.ts +4 -0
  110. package/src/meeting/index.ts +260 -23
  111. package/src/meeting/util.ts +20 -2
  112. package/src/meetings/index.ts +109 -43
  113. package/src/meetings/meetings.types.ts +19 -0
  114. package/src/meetings/request.ts +43 -0
  115. package/src/meetings/util.ts +80 -1
  116. package/src/member/index.ts +10 -0
  117. package/src/member/types.ts +1 -0
  118. package/src/member/util.ts +3 -0
  119. package/src/metrics/constants.ts +3 -0
  120. package/src/multistream/receiveSlot.ts +18 -0
  121. package/src/reactions/reactions.type.ts +3 -0
  122. package/src/recording-controller/index.ts +1 -2
  123. package/src/webinar/index.ts +88 -21
  124. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  125. package/test/unit/spec/breakouts/breakout.ts +9 -3
  126. package/test/unit/spec/breakouts/index.ts +2 -0
  127. package/test/unit/spec/controls-options-manager/index.js +140 -29
  128. package/test/unit/spec/controls-options-manager/util.js +165 -0
  129. package/test/unit/spec/hashTree/hashTreeParser.ts +1263 -157
  130. package/test/unit/spec/hashTree/utils.ts +88 -1
  131. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  132. package/test/unit/spec/interpretation/index.ts +26 -4
  133. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  134. package/test/unit/spec/locus-info/index.js +475 -81
  135. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  136. package/test/unit/spec/meeting/index.js +902 -14
  137. package/test/unit/spec/meeting/muteState.js +3 -0
  138. package/test/unit/spec/meeting/utils.js +33 -0
  139. package/test/unit/spec/meetings/index.js +309 -10
  140. package/test/unit/spec/meetings/request.js +141 -0
  141. package/test/unit/spec/meetings/utils.js +161 -0
  142. package/test/unit/spec/member/index.js +7 -0
  143. package/test/unit/spec/member/util.js +24 -0
  144. package/test/unit/spec/recording-controller/index.js +9 -8
  145. package/test/unit/spec/webinar/index.ts +81 -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,
@@ -1977,6 +1980,113 @@ describe('plugin-meetings', () => {
1977
1980
  fakeProcessedReaction
1978
1981
  );
1979
1982
  });
1983
+
1984
+ [
1985
+ {
1986
+ title: 'should skip a reaction when the default relay route does not match the LLM binding',
1987
+ isPracticeSessionConnected: false,
1988
+ route: 'wrong-default-route',
1989
+ defaultBinding: 'default-route',
1990
+ practiceBinding: 'practice-route',
1991
+ shouldProcess: false,
1992
+ expectedSessionLabel: 'default session',
1993
+ },
1994
+ {
1995
+ title: 'should process a reaction when the default relay route matches the LLM binding',
1996
+ isPracticeSessionConnected: false,
1997
+ route: 'default-route',
1998
+ defaultBinding: 'default-route',
1999
+ practiceBinding: 'practice-route',
2000
+ shouldProcess: true,
2001
+ },
2002
+ {
2003
+ title:
2004
+ 'should process a reaction when the practice-session relay route matches the practice-session LLM binding',
2005
+ isPracticeSessionConnected: true,
2006
+ route: 'practice-route',
2007
+ defaultBinding: 'default-route',
2008
+ practiceBinding: 'practice-route',
2009
+ shouldProcess: true,
2010
+ },
2011
+ {
2012
+ title:
2013
+ 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding',
2014
+ isPracticeSessionConnected: true,
2015
+ route: 'default-route',
2016
+ defaultBinding: 'default-route',
2017
+ practiceBinding: 'practice-route',
2018
+ shouldProcess: false,
2019
+ expectedSessionLabel: 'practice session',
2020
+ },
2021
+ ].forEach(
2022
+ ({
2023
+ title,
2024
+ isPracticeSessionConnected,
2025
+ route,
2026
+ defaultBinding,
2027
+ practiceBinding,
2028
+ shouldProcess,
2029
+ expectedSessionLabel,
2030
+ }) => {
2031
+ it(title, () => {
2032
+ meeting.isReactionsSupported = sinon.stub().returns(true);
2033
+ meeting.config.receiveReactions = true;
2034
+ const fakeSendersName = 'Fake reactors name';
2035
+ meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName});
2036
+ webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => {
2037
+ return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected;
2038
+ });
2039
+ webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => {
2040
+ if (llmSessionId === LLM_PRACTICE_SESSION) {
2041
+ return practiceBinding;
2042
+ }
2043
+
2044
+ return defaultBinding;
2045
+ });
2046
+ const fakeReactionPayload = {
2047
+ type: 'fake_type',
2048
+ codepoints: 'fake_codepoints',
2049
+ shortcodes: 'fake_shortcodes',
2050
+ };
2051
+ const fakeSenderPayload = {
2052
+ participantId: 'fake_participant_id',
2053
+ };
2054
+ const fakeRelayEvent = {
2055
+ headers: {route},
2056
+ data: {
2057
+ relayType: REACTION_RELAY_TYPES.REACTION,
2058
+ reaction: fakeReactionPayload,
2059
+ sender: fakeSenderPayload,
2060
+ },
2061
+ };
2062
+ const fakeProcessedReaction = {
2063
+ reaction: fakeReactionPayload,
2064
+ sender: {
2065
+ id: fakeSenderPayload.participantId,
2066
+ name: fakeSendersName,
2067
+ },
2068
+ };
2069
+
2070
+ TriggerProxy.trigger.resetHistory();
2071
+ meeting.processRelayEvent(fakeRelayEvent);
2072
+
2073
+ if (shouldProcess) {
2074
+ assert.calledWith(
2075
+ TriggerProxy.trigger,
2076
+ sinon.match.instanceOf(Meeting),
2077
+ {
2078
+ file: 'meeting/index',
2079
+ function: 'join',
2080
+ },
2081
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
2082
+ fakeProcessedReaction
2083
+ );
2084
+ } else {
2085
+ assert.notCalled(TriggerProxy.trigger);
2086
+ }
2087
+ });
2088
+ }
2089
+ );
1980
2090
  });
1981
2091
 
1982
2092
  describe('#handleLLMOnline', () => {
@@ -4534,6 +4644,297 @@ describe('plugin-meetings', () => {
4534
4644
  },
4535
4645
  });
4536
4646
  });
4647
+
4648
+ describe('handles STATS_UPDATE event for SRTP cipher detection', () => {
4649
+ it('emits MEETING_SRTP_CIPHER_UPDATED event when srtpCipher is found in transport stats', async () => {
4650
+ const fakeStats = new Map([
4651
+ [
4652
+ 'transport-1',
4653
+ {
4654
+ type: 'transport',
4655
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4656
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4657
+ },
4658
+ ],
4659
+ [
4660
+ 'outbound-rtp-1',
4661
+ {
4662
+ type: 'outbound-rtp',
4663
+ ssrc: 12345,
4664
+ },
4665
+ ],
4666
+ ]);
4667
+
4668
+ statsAnalyzerStub.emit(
4669
+ {file: 'test', function: 'test'},
4670
+ StatsAnalyzerEventNames.STATS_UPDATE,
4671
+ {stats: fakeStats}
4672
+ );
4673
+
4674
+ assert.calledWith(
4675
+ TriggerProxy.trigger,
4676
+ sinon.match.instanceOf(Meeting),
4677
+ {
4678
+ file: 'meeting/index',
4679
+ function: 'setupStatsAnalyzerEventHandlers',
4680
+ },
4681
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4682
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4683
+ );
4684
+
4685
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4686
+ });
4687
+
4688
+ it('updates meeting.mediaProperties.srtpCipher when cipher changes', async () => {
4689
+ const firstStats = new Map([
4690
+ [
4691
+ 'transport-1',
4692
+ {
4693
+ type: 'transport',
4694
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4695
+ },
4696
+ ],
4697
+ ]);
4698
+
4699
+ statsAnalyzerStub.emit(
4700
+ {file: 'test', function: 'test'},
4701
+ StatsAnalyzerEventNames.STATS_UPDATE,
4702
+ {stats: firstStats}
4703
+ );
4704
+
4705
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4706
+
4707
+ const secondStats = new Map([
4708
+ [
4709
+ 'transport-1',
4710
+ {
4711
+ type: 'transport',
4712
+ srtpCipher: 'AEAD_AES_256_GCM',
4713
+ },
4714
+ ],
4715
+ ]);
4716
+
4717
+ TriggerProxy.trigger.resetHistory();
4718
+
4719
+ statsAnalyzerStub.emit(
4720
+ {file: 'test', function: 'test'},
4721
+ StatsAnalyzerEventNames.STATS_UPDATE,
4722
+ {stats: secondStats}
4723
+ );
4724
+
4725
+ assert.calledWith(
4726
+ TriggerProxy.trigger,
4727
+ sinon.match.instanceOf(Meeting),
4728
+ {
4729
+ file: 'meeting/index',
4730
+ function: 'setupStatsAnalyzerEventHandlers',
4731
+ },
4732
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4733
+ {srtpCipher: 'AEAD_AES_256_GCM'}
4734
+ );
4735
+
4736
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AEAD_AES_256_GCM');
4737
+ });
4738
+
4739
+ it('does not emit event when srtpCipher has not changed', async () => {
4740
+ const firstStats = new Map([
4741
+ [
4742
+ 'transport-1',
4743
+ {
4744
+ type: 'transport',
4745
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4746
+ },
4747
+ ],
4748
+ ]);
4749
+
4750
+ statsAnalyzerStub.emit(
4751
+ {file: 'test', function: 'test'},
4752
+ StatsAnalyzerEventNames.STATS_UPDATE,
4753
+ {stats: firstStats}
4754
+ );
4755
+
4756
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4757
+
4758
+ TriggerProxy.trigger.resetHistory();
4759
+
4760
+ // Emit same cipher again
4761
+ statsAnalyzerStub.emit(
4762
+ {file: 'test', function: 'test'},
4763
+ StatsAnalyzerEventNames.STATS_UPDATE,
4764
+ {stats: firstStats}
4765
+ );
4766
+
4767
+ // Should not trigger event again
4768
+ assert.neverCalledWith(
4769
+ TriggerProxy.trigger,
4770
+ sinon.match.instanceOf(Meeting),
4771
+ sinon.match.any,
4772
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4773
+ sinon.match.any
4774
+ );
4775
+
4776
+ // Cipher should remain the same
4777
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4778
+ });
4779
+
4780
+ it('does not emit event when stats contain no transport with srtpCipher', async () => {
4781
+ const fakeStats = new Map([
4782
+ [
4783
+ 'outbound-rtp-1',
4784
+ {
4785
+ type: 'outbound-rtp',
4786
+ ssrc: 12345,
4787
+ },
4788
+ ],
4789
+ [
4790
+ 'inbound-rtp-1',
4791
+ {
4792
+ type: 'inbound-rtp',
4793
+ ssrc: 67890,
4794
+ },
4795
+ ],
4796
+ ]);
4797
+
4798
+ statsAnalyzerStub.emit(
4799
+ {file: 'test', function: 'test'},
4800
+ StatsAnalyzerEventNames.STATS_UPDATE,
4801
+ {stats: fakeStats}
4802
+ );
4803
+
4804
+ assert.neverCalledWith(
4805
+ TriggerProxy.trigger,
4806
+ sinon.match.instanceOf(Meeting),
4807
+ sinon.match.any,
4808
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4809
+ sinon.match.any
4810
+ );
4811
+
4812
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4813
+ });
4814
+
4815
+ it('does not emit event when transport stat has no srtpCipher property', async () => {
4816
+ const fakeStats = new Map([
4817
+ [
4818
+ 'transport-1',
4819
+ {
4820
+ type: 'transport',
4821
+ dtlsCipher: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
4822
+ // no srtpCipher property
4823
+ },
4824
+ ],
4825
+ ]);
4826
+
4827
+ statsAnalyzerStub.emit(
4828
+ {file: 'test', function: 'test'},
4829
+ StatsAnalyzerEventNames.STATS_UPDATE,
4830
+ {stats: fakeStats}
4831
+ );
4832
+
4833
+ assert.neverCalledWith(
4834
+ TriggerProxy.trigger,
4835
+ sinon.match.instanceOf(Meeting),
4836
+ sinon.match.any,
4837
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4838
+ sinon.match.any
4839
+ );
4840
+
4841
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4842
+ });
4843
+
4844
+ it('uses first transport with srtpCipher when multiple transports exist', async () => {
4845
+ const fakeStats = new Map([
4846
+ [
4847
+ 'transport-1',
4848
+ {
4849
+ type: 'transport',
4850
+ srtpCipher: 'AES_CM_128_HMAC_SHA1_80',
4851
+ },
4852
+ ],
4853
+ [
4854
+ 'transport-2',
4855
+ {
4856
+ type: 'transport',
4857
+ srtpCipher: 'AEAD_AES_256_GCM',
4858
+ },
4859
+ ],
4860
+ [
4861
+ 'outbound-rtp-1',
4862
+ {
4863
+ type: 'outbound-rtp',
4864
+ ssrc: 12345,
4865
+ },
4866
+ ],
4867
+ ]);
4868
+
4869
+ statsAnalyzerStub.emit(
4870
+ {file: 'test', function: 'test'},
4871
+ StatsAnalyzerEventNames.STATS_UPDATE,
4872
+ {stats: fakeStats}
4873
+ );
4874
+
4875
+ assert.calledWith(
4876
+ TriggerProxy.trigger,
4877
+ sinon.match.instanceOf(Meeting),
4878
+ {
4879
+ file: 'meeting/index',
4880
+ function: 'setupStatsAnalyzerEventHandlers',
4881
+ },
4882
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4883
+ {srtpCipher: 'AES_CM_128_HMAC_SHA1_80'}
4884
+ );
4885
+
4886
+ assert.equal(meeting.mediaProperties.srtpCipher, 'AES_CM_128_HMAC_SHA1_80');
4887
+ });
4888
+
4889
+ it('handles empty stats map without errors', async () => {
4890
+ const emptyStats = new Map();
4891
+
4892
+ statsAnalyzerStub.emit(
4893
+ {file: 'test', function: 'test'},
4894
+ StatsAnalyzerEventNames.STATS_UPDATE,
4895
+ {stats: emptyStats}
4896
+ );
4897
+
4898
+ assert.neverCalledWith(
4899
+ TriggerProxy.trigger,
4900
+ sinon.match.instanceOf(Meeting),
4901
+ sinon.match.any,
4902
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
4903
+ sinon.match.any
4904
+ );
4905
+
4906
+ assert.isUndefined(meeting.mediaProperties.srtpCipher);
4907
+ });
4908
+
4909
+ it('logs cipher change when cipher is updated', async () => {
4910
+ const loggerSpy = sinon.spy(LoggerProxy.logger, 'info');
4911
+
4912
+ meeting.mediaProperties.srtpCipher = 'AES_CM_128_HMAC_SHA1_80';
4913
+
4914
+ const newStats = new Map([
4915
+ [
4916
+ 'transport-1',
4917
+ {
4918
+ type: 'transport',
4919
+ srtpCipher: 'AEAD_AES_256_GCM',
4920
+ },
4921
+ ],
4922
+ ]);
4923
+
4924
+ statsAnalyzerStub.emit(
4925
+ {file: 'test', function: 'test'},
4926
+ StatsAnalyzerEventNames.STATS_UPDATE,
4927
+ {stats: newStats}
4928
+ );
4929
+
4930
+ assert.calledWithMatch(
4931
+ loggerSpy,
4932
+ sinon.match(/SRTP cipher changed from AES_CM_128_HMAC_SHA1_80 to AEAD_AES_256_GCM/)
4933
+ );
4934
+
4935
+ loggerSpy.restore();
4936
+ });
4937
+ });
4537
4938
  });
4538
4939
 
4539
4940
  describe('handles StatsMonitor events', () => {
@@ -6429,6 +6830,9 @@ describe('plugin-meetings', () => {
6429
6830
 
6430
6831
  meeting.annotation.deregisterEvents = sinon.stub();
6431
6832
  webex.internal.llm.off = sinon.stub();
6833
+ webex.internal.mercury.off = sinon.stub();
6834
+ meeting.mercuryOnlineHandler = sinon.stub();
6835
+ meeting.mercuryOfflineHandler = sinon.stub();
6432
6836
 
6433
6837
  // A meeting needs to be joined to leave
6434
6838
  meeting.meetingState = 'ACTIVE';
@@ -6452,6 +6856,67 @@ describe('plugin-meetings', () => {
6452
6856
  assert.calledOnce(meeting.clearMeetingData);
6453
6857
  });
6454
6858
 
6859
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /leave', async () => {
6860
+ const onlineHandler = meeting.mercuryOnlineHandler;
6861
+ const offlineHandler = meeting.mercuryOfflineHandler;
6862
+
6863
+ await meeting.leave();
6864
+
6865
+ // All llm/mercury consumers (direct listeners, voicea transcription,
6866
+ // annotation) must be detached before the /leave request so that
6867
+ // in-flight events do not trigger unnecessary Locus syncs
6868
+ // (per Locus team recommendation).
6869
+ assert.callOrder(
6870
+ webex.internal.llm.off,
6871
+ webex.internal.mercury.off,
6872
+ meeting.stopTranscription,
6873
+ meeting.annotation.deregisterEvents,
6874
+ meeting.meetingRequest.leaveMeeting
6875
+ );
6876
+ assert.calledWithExactly(
6877
+ webex.internal.llm.off,
6878
+ 'event:relay.event',
6879
+ meeting.processRelayEvent
6880
+ );
6881
+ assert.calledWithExactly(
6882
+ webex.internal.llm.off,
6883
+ LOCUS_LLM_EVENT,
6884
+ meeting.processLocusLLMEvent
6885
+ );
6886
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
6887
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
6888
+ assert.isUndefined(meeting.mercuryOnlineHandler);
6889
+ assert.isUndefined(meeting.mercuryOfflineHandler);
6890
+ assert.calledOnceWithExactly(meeting.stopTranscription);
6891
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
6892
+ assert.isUndefined(meeting.transcription);
6893
+ });
6894
+
6895
+ it('tears down llm/mercury/transcription/annotation even when /leave rejects', async () => {
6896
+ const onlineHandler = meeting.mercuryOnlineHandler;
6897
+ const offlineHandler = meeting.mercuryOfflineHandler;
6898
+ meeting.meetingRequest.leaveMeeting = sinon
6899
+ .stub()
6900
+ .returns(Promise.reject(new Error('leave failed')));
6901
+
6902
+ await meeting.leave().catch(() => {});
6903
+
6904
+ assert.calledWithExactly(
6905
+ webex.internal.llm.off,
6906
+ 'event:relay.event',
6907
+ meeting.processRelayEvent
6908
+ );
6909
+ assert.calledWithExactly(
6910
+ webex.internal.llm.off,
6911
+ LOCUS_LLM_EVENT,
6912
+ meeting.processLocusLLMEvent
6913
+ );
6914
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
6915
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
6916
+ assert.calledOnceWithExactly(meeting.stopTranscription);
6917
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
6918
+ });
6919
+
6455
6920
  it('should reset call diagnostic latencies correctly', async () => {
6456
6921
  const leave = meeting.leave();
6457
6922
 
@@ -8459,6 +8924,9 @@ describe('plugin-meetings', () => {
8459
8924
 
8460
8925
  meeting.annotation.deregisterEvents = sinon.stub();
8461
8926
  webex.internal.llm.off = sinon.stub();
8927
+ webex.internal.mercury.off = sinon.stub();
8928
+ meeting.mercuryOnlineHandler = sinon.stub();
8929
+ meeting.mercuryOfflineHandler = sinon.stub();
8462
8930
 
8463
8931
  // A meeting needs to be joined to end
8464
8932
  meeting.meetingState = 'ACTIVE';
@@ -8481,6 +8949,66 @@ describe('plugin-meetings', () => {
8481
8949
  assert.calledOnce(meeting?.unsetPeerConnections);
8482
8950
  assert.calledOnce(meeting?.clearMeetingData);
8483
8951
  });
8952
+
8953
+ it('stops listening for LLM/Mercury and tears down transcription and annotation before calling Locus /end', async () => {
8954
+ const onlineHandler = meeting.mercuryOnlineHandler;
8955
+ const offlineHandler = meeting.mercuryOfflineHandler;
8956
+
8957
+ await meeting.endMeetingForAll();
8958
+
8959
+ // All llm/mercury consumers (direct listeners, voicea transcription,
8960
+ // annotation) must be detached before the /end request so that
8961
+ // in-flight events do not trigger unnecessary Locus syncs
8962
+ // (per Locus team recommendation).
8963
+ assert.callOrder(
8964
+ webex.internal.llm.off,
8965
+ webex.internal.mercury.off,
8966
+ meeting.stopTranscription,
8967
+ meeting.annotation.deregisterEvents,
8968
+ meeting.meetingRequest.endMeetingForAll
8969
+ );
8970
+ assert.calledWithExactly(
8971
+ webex.internal.llm.off,
8972
+ 'event:relay.event',
8973
+ meeting.processRelayEvent
8974
+ );
8975
+ assert.calledWithExactly(
8976
+ webex.internal.llm.off,
8977
+ LOCUS_LLM_EVENT,
8978
+ meeting.processLocusLLMEvent
8979
+ );
8980
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
8981
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
8982
+ assert.isUndefined(meeting.mercuryOnlineHandler);
8983
+ assert.isUndefined(meeting.mercuryOfflineHandler);
8984
+ assert.calledOnceWithExactly(meeting.stopTranscription);
8985
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
8986
+ });
8987
+
8988
+ it('tears down llm/mercury/transcription/annotation even when /end rejects', async () => {
8989
+ const onlineHandler = meeting.mercuryOnlineHandler;
8990
+ const offlineHandler = meeting.mercuryOfflineHandler;
8991
+ meeting.meetingRequest.endMeetingForAll = sinon
8992
+ .stub()
8993
+ .returns(Promise.reject(new Error('end failed')));
8994
+
8995
+ await meeting.endMeetingForAll().catch(() => {});
8996
+
8997
+ assert.calledWithExactly(
8998
+ webex.internal.llm.off,
8999
+ 'event:relay.event',
9000
+ meeting.processRelayEvent
9001
+ );
9002
+ assert.calledWithExactly(
9003
+ webex.internal.llm.off,
9004
+ LOCUS_LLM_EVENT,
9005
+ meeting.processLocusLLMEvent
9006
+ );
9007
+ assert.calledWithExactly(webex.internal.mercury.off, ONLINE, onlineHandler);
9008
+ assert.calledWithExactly(webex.internal.mercury.off, OFFLINE, offlineHandler);
9009
+ assert.calledOnceWithExactly(meeting.stopTranscription);
9010
+ assert.calledOnceWithExactly(meeting.annotation.deregisterEvents);
9011
+ });
8484
9012
  });
8485
9013
 
8486
9014
  describe('#moveTo', () => {
@@ -10971,6 +11499,92 @@ describe('plugin-meetings', () => {
10971
11499
  );
10972
11500
  });
10973
11501
 
11502
+ const recordingTestCases = [
11503
+ {
11504
+ description: 'triggers MEETING_STARTED_RECORDING when state is RECORDING',
11505
+ state: RECORDING_STATE.RECORDING,
11506
+ expectedEvent: EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11507
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11508
+ },
11509
+ {
11510
+ description: 'triggers MEETING_STOPPED_RECORDING when state is IDLE',
11511
+ state: RECORDING_STATE.IDLE,
11512
+ expectedEvent: EVENT_TRIGGERS.MEETING_STOPPED_RECORDING,
11513
+ expectedRecordingState: RECORDING_STATE.IDLE,
11514
+ },
11515
+ {
11516
+ description: 'triggers MEETING_PAUSED_RECORDING when state is PAUSED',
11517
+ state: RECORDING_STATE.PAUSED,
11518
+ expectedEvent: EVENT_TRIGGERS.MEETING_PAUSED_RECORDING,
11519
+ expectedRecordingState: RECORDING_STATE.PAUSED,
11520
+ },
11521
+ {
11522
+ description:
11523
+ 'triggers MEETING_RESUMED_RECORDING and sets state to RECORDING when state is RESUMED',
11524
+ state: RECORDING_STATE.RESUMED,
11525
+ expectedEvent: EVENT_TRIGGERS.MEETING_RESUMED_RECORDING,
11526
+ expectedRecordingState: RECORDING_STATE.RECORDING,
11527
+ },
11528
+ ];
11529
+
11530
+ recordingTestCases.forEach(({description, state, expectedEvent, expectedRecordingState}) => {
11531
+ it(`listens to CONTROLS_RECORDING_UPDATED - ${description}`, async () => {
11532
+ const modifiedBy = 'user-id-123';
11533
+ const lastModified = '2026-01-01T00:00:00Z';
11534
+
11535
+ await meeting.locusInfo.emitScoped(
11536
+ {function: 'test', file: 'test'},
11537
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11538
+ {state, modifiedBy, lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined}
11539
+ );
11540
+
11541
+ assert.deepEqual(meeting.recording, {
11542
+ state: expectedRecordingState,
11543
+ modifiedBy,
11544
+ lastModified,
11545
+ modifiedByServiceAppName: undefined,
11546
+ modifiedByServiceAppId: undefined,
11547
+ });
11548
+
11549
+ assert.calledWith(
11550
+ TriggerProxy.trigger,
11551
+ meeting,
11552
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11553
+ expectedEvent,
11554
+ meeting.recording
11555
+ );
11556
+ });
11557
+ });
11558
+
11559
+ it('listens to CONTROLS_RECORDING_UPDATED and includes modifiedByServiceAppName and modifiedByServiceAppId when present', async () => {
11560
+ const modifiedBy = 'user-id-123';
11561
+ const lastModified = '2026-01-01T00:00:00Z';
11562
+ const modifiedByServiceAppName = 'My Bot';
11563
+ const modifiedByServiceAppId = 'app-id-123';
11564
+
11565
+ await meeting.locusInfo.emitScoped(
11566
+ {function: 'test', file: 'test'},
11567
+ LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
11568
+ {state: RECORDING_STATE.RECORDING, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}
11569
+ );
11570
+
11571
+ assert.deepEqual(meeting.recording, {
11572
+ state: RECORDING_STATE.RECORDING,
11573
+ modifiedBy,
11574
+ lastModified,
11575
+ modifiedByServiceAppName,
11576
+ modifiedByServiceAppId,
11577
+ });
11578
+
11579
+ assert.calledWith(
11580
+ TriggerProxy.trigger,
11581
+ meeting,
11582
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
11583
+ EVENT_TRIGGERS.MEETING_STARTED_RECORDING,
11584
+ meeting.recording
11585
+ );
11586
+ });
11587
+
10974
11588
  it('listens to the locus interpretation update event', () => {
10975
11589
  const interpretation = {
10976
11590
  siLanguages: [{languageCode: 20, languageName: 'en'}],
@@ -11024,6 +11638,7 @@ describe('plugin-meetings', () => {
11024
11638
  meeting.annotation.locusUrlUpdate = sinon.stub();
11025
11639
  meeting.simultaneousInterpretation.locusUrlUpdate = sinon.stub();
11026
11640
  meeting.webinar.locusUrlUpdate = sinon.stub();
11641
+ meeting.aiEnableRequest.locusUrlUpdate = sinon.stub();
11027
11642
 
11028
11643
  meeting.locusInfo.emit(
11029
11644
  {function: 'test', file: 'test'},
@@ -11038,6 +11653,7 @@ describe('plugin-meetings', () => {
11038
11653
  assert.calledWith(meeting.controlsOptionsManager.setLocusUrl, newLocusUrl, false);
11039
11654
  assert.calledWith(meeting.simultaneousInterpretation.locusUrlUpdate, newLocusUrl);
11040
11655
  assert.calledWith(meeting.webinar.locusUrlUpdate, newLocusUrl);
11656
+ assert.calledWith(meeting.aiEnableRequest.locusUrlUpdate, newLocusUrl);
11041
11657
  assert.equal(meeting.locusUrl, newLocusUrl);
11042
11658
  assert(meeting.locusId, '12345');
11043
11659
 
@@ -11353,6 +11969,93 @@ describe('plugin-meetings', () => {
11353
11969
  });
11354
11970
  });
11355
11971
 
11972
+ describe('#finalizeMeetingAfterInitialLocusSetup', () => {
11973
+ it('refreshes destination from synced locus when destination type is LOCUS_ID', () => {
11974
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11975
+
11976
+ meeting.destinationType = DESTINATION_TYPE.LOCUS_ID;
11977
+ meeting.destination = {info: {topic: 'old'}};
11978
+
11979
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
11980
+
11981
+ assert.equal(meeting.destination, syncedLocus);
11982
+ });
11983
+
11984
+ it('does not refresh destination when destination type is not LOCUS_ID', () => {
11985
+ const syncedLocus = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
11986
+ const originalDestination = {destination: 'original-destination'};
11987
+
11988
+ meeting.destinationType = DESTINATION_TYPE.CONVERSATION_URL;
11989
+ meeting.destination = originalDestination;
11990
+
11991
+ meeting.finalizeMeetingAfterInitialLocusSetup(syncedLocus);
11992
+
11993
+ assert.equal(meeting.destination, originalDestination);
11994
+ });
11995
+
11996
+ it('fetches meeting info when meetingInfo is empty and destination has info', () => {
11997
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
11998
+
11999
+ meeting.meetingInfo = {};
12000
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12001
+
12002
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12003
+
12004
+ assert.calledOnceWithExactly(fetchMeetingInfoStub, {});
12005
+ });
12006
+
12007
+ it('does not fetch meeting info when destination has no info', () => {
12008
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12009
+
12010
+ meeting.meetingInfo = {};
12011
+ meeting.destination = {url: 'https://locus.example.com/locus/123'};
12012
+
12013
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12014
+
12015
+ assert.notCalled(fetchMeetingInfoStub);
12016
+ });
12017
+
12018
+ it('does not fetch meeting info when meetingInfo is already populated', () => {
12019
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12020
+
12021
+ meeting.meetingInfo = {meetingJoinUrl: 'https://example.com/join/abc'};
12022
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12023
+
12024
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12025
+
12026
+ assert.notCalled(fetchMeetingInfoStub);
12027
+ });
12028
+
12029
+ it('does not fetch meeting info when delayed fetch timer is already scheduled', () => {
12030
+ const fetchMeetingInfoStub = sinon.stub(meeting, 'fetchMeetingInfo').resolves();
12031
+
12032
+ meeting.meetingInfo = {};
12033
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12034
+ meeting.fetchMeetingInfoTimeoutId = 42;
12035
+
12036
+ meeting.finalizeMeetingAfterInitialLocusSetup({});
12037
+
12038
+ assert.notCalled(fetchMeetingInfoStub);
12039
+ });
12040
+
12041
+ it('swallows async fetchMeetingInfo errors and logs info', async () => {
12042
+ const error = new Error('fetch failed');
12043
+
12044
+ meeting.meetingInfo = {};
12045
+ meeting.destination = {url: 'https://locus.example.com/locus/123', info: {topic: 'x'}};
12046
+ sinon.stub(meeting, 'fetchMeetingInfo').returns(Promise.reject(error));
12047
+ const loggerInfoStub = sinon.stub(LoggerProxy.logger, 'info');
12048
+
12049
+ await meeting.finalizeMeetingAfterInitialLocusSetup({});
12050
+
12051
+ assert.calledOnce(loggerInfoStub);
12052
+ assert.match(
12053
+ loggerInfoStub.firstCall.args[0],
12054
+ /Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: fetch failed/
12055
+ );
12056
+ });
12057
+ });
12058
+
11356
12059
  describe('#emailInput', () => {
11357
12060
  it('should set the email input', () => {
11358
12061
  assert.notOk(meeting.emailInput);
@@ -11955,6 +12658,7 @@ describe('plugin-meetings', () => {
11955
12658
  let showAutoEndMeetingWarningSpy;
11956
12659
  let canAttendeeRequestAiAssistantEnabledSpy;
11957
12660
  let attendeeRequestAiAssistantDeclinedAllSpy;
12661
+ let isAnonymizeDisplayNamesEnabledSpy;
11958
12662
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11959
12663
 
11960
12664
  beforeEach(() => {
@@ -12003,6 +12707,10 @@ describe('plugin-meetings', () => {
12003
12707
  MeetingUtil,
12004
12708
  'attendeeRequestAiAssistantDeclinedAll'
12005
12709
  );
12710
+ isAnonymizeDisplayNamesEnabledSpy = sinon.spy(
12711
+ MeetingUtil,
12712
+ 'isAnonymizeDisplayNamesEnabled'
12713
+ );
12006
12714
  });
12007
12715
 
12008
12716
  afterEach(() => {
@@ -12011,6 +12719,7 @@ describe('plugin-meetings', () => {
12011
12719
  showAutoEndMeetingWarningSpy.restore();
12012
12720
  canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
12721
  attendeeRequestAiAssistantDeclinedAllSpy.restore();
12722
+ isAnonymizeDisplayNamesEnabledSpy.restore();
12014
12723
  });
12015
12724
 
12016
12725
  forEach(
@@ -12568,6 +13277,7 @@ describe('plugin-meetings', () => {
12568
13277
  meeting.roles
12569
13278
  );
12570
13279
  assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
13280
+ assert.calledWith(isAnonymizeDisplayNamesEnabledSpy, userDisplayHints);
12571
13281
 
12572
13282
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12573
13283
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -13139,7 +13849,9 @@ describe('plugin-meetings', () => {
13139
13849
  info: {datachannelUrl: 'a datachannel url'},
13140
13850
  };
13141
13851
 
13142
- webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
13852
+ webex.internal.llm.getDatachannelToken
13853
+ .withArgs('llm-default-session')
13854
+ .returns('token-123');
13143
13855
 
13144
13856
  await meeting.updateLLMConnection();
13145
13857
 
@@ -13193,6 +13905,131 @@ describe('plugin-meetings', () => {
13193
13905
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
13906
  });
13195
13907
 
13908
+ describe('ownership tag', () => {
13909
+ beforeEach(() => {
13910
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
13911
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
13912
+ // real LLM singleton behavior so the finally-block release in
13913
+ // cleanupLLMConneciton is reflected in subsequent reads.
13914
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
13915
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
13916
+ webex.internal.llm.getOwnerMeetingId.returns(id);
13917
+ });
13918
+ });
13919
+
13920
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
13921
+ meeting.joinedWith = {state: 'JOINED'};
13922
+ webex.internal.llm.isConnected.returns(true);
13923
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13924
+ // Locus/datachannel URL mismatch is the *normal* case when
13925
+ // another meeting owns the live socket -- each meeting has its
13926
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
13927
+ // because doing so would tear down the owning meeting's healthy
13928
+ // LLM socket and break its data channel.
13929
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
13930
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
13931
+ meeting.locusInfo = {
13932
+ url: 'a different url',
13933
+ info: {datachannelUrl: 'a different datachannel url'},
13934
+ self: {},
13935
+ };
13936
+
13937
+ const result = await meeting.updateLLMConnection();
13938
+
13939
+ assert.equal(result, undefined);
13940
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13941
+ assert.notCalled(webex.internal.llm.registerAndConnect);
13942
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
13943
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
13944
+ });
13945
+
13946
+
13947
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
13948
+ meeting.joinedWith = {state: 'JOINED'};
13949
+ webex.internal.llm.isConnected.returns(true);
13950
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13951
+ webex.internal.llm.getLocusUrl.returns('a url');
13952
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13953
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13954
+ meeting.locusInfo = {
13955
+ url: 'a different url',
13956
+ info: {datachannelUrl: 'a datachannel url'},
13957
+ self: {},
13958
+ };
13959
+
13960
+ try {
13961
+ await meeting.updateLLMConnection();
13962
+ } catch (e) {
13963
+ /* updateLLMConnection may reject when cleanup throws */
13964
+ }
13965
+
13966
+ // The owner-eligible finally branch must release the tag so a
13967
+ // subsequent reconnect attempt from any meeting is not blocked.
13968
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
13969
+ });
13970
+
13971
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
13972
+ meeting.joinedWith = {state: 'JOINED'};
13973
+ webex.internal.llm.isConnected.returns(true);
13974
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13975
+ webex.internal.llm.getLocusUrl.returns('a url');
13976
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13977
+ meeting.locusInfo = {
13978
+ url: 'a different url',
13979
+ info: {datachannelUrl: 'a datachannel url'},
13980
+ self: {},
13981
+ };
13982
+
13983
+ await meeting.updateLLMConnection();
13984
+
13985
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13986
+ code: 3050,
13987
+ reason: 'done (permanent)',
13988
+ });
13989
+ assert.calledWithExactly(
13990
+ webex.internal.llm.registerAndConnect,
13991
+ 'a different url',
13992
+ 'a datachannel url',
13993
+ undefined
13994
+ );
13995
+ // setOwnerMeetingId is called twice: first with undefined in
13996
+ // cleanupLLMConneciton's finally block (so a failed disconnect
13997
+ // cannot leave a stale owner), then with this meeting's id
13998
+ // after registerAndConnect resolves.
13999
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
14000
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
14001
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
14002
+ });
14003
+
14004
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
14005
+ meeting.joinedWith = {state: 'JOINED'};
14006
+ webex.internal.llm.isConnected.returns(false);
14007
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14008
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14009
+
14010
+ await meeting.updateLLMConnection();
14011
+
14012
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14013
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14014
+ });
14015
+
14016
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
14017
+ // Defensive path: if the LLM reports not-connected but an old
14018
+ // ownerId is still present (e.g. race before a successful
14019
+ // connections.delete), this meeting can still claim a fresh
14020
+ // connection.
14021
+ meeting.joinedWith = {state: 'JOINED'};
14022
+ webex.internal.llm.isConnected.returns(false);
14023
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
14024
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
14025
+
14026
+ await meeting.updateLLMConnection();
14027
+
14028
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
14029
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
14030
+ });
14031
+ });
14032
+
13196
14033
  describe('#clearMeetingData', () => {
13197
14034
  beforeEach(() => {
13198
14035
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13224,10 +14061,13 @@ describe('plugin-meetings', () => {
13224
14061
  meeting.processLocusLLMEvent
13225
14062
  );
13226
14063
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
- assert.calledOnce(meeting.stopTranscription);
13228
- assert.isUndefined(meeting.transcription);
13229
14064
  assert.calledOnce(meeting.clearDataChannelToken);
13230
- assert.calledOnce(meeting.annotation.deregisterEvents);
14065
+ // stopTranscription and annotation.deregisterEvents are not
14066
+ // called here: they run in stopListeningForMeetingEvents()
14067
+ // before /leave to avoid double-emitting
14068
+ // MEETING_STOPPED_RECEIVING_TRANSCRIPTION.
14069
+ assert.notCalled(meeting.stopTranscription);
14070
+ assert.notCalled(meeting.annotation.deregisterEvents);
13231
14071
  });
13232
14072
  it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
14073
  webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
@@ -13246,19 +14086,67 @@ describe('plugin-meetings', () => {
13246
14086
  meeting.processLocusLLMEvent
13247
14087
  );
13248
14088
  assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
- assert.calledOnce(meeting.stopTranscription);
13250
- assert.isUndefined(meeting.transcription);
13251
14089
  assert.calledOnce(meeting.clearDataChannelToken);
13252
- assert.calledOnce(meeting.annotation.deregisterEvents);
14090
+ assert.notCalled(meeting.stopTranscription);
14091
+ assert.notCalled(meeting.annotation.deregisterEvents);
13253
14092
  });
13254
- it('always calls stopTranscription even when transcription is undefined', async () => {
13255
- meeting.transcription = undefined;
13256
14093
 
13257
- await meeting.clearMeetingData();
14094
+ describe('ownership tag', () => {
14095
+ beforeEach(() => {
14096
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
14097
+ });
13258
14098
 
13259
- assert.calledOnce(meeting.stopTranscription);
13260
- assert.isUndefined(meeting.transcription);
13261
- assert.calledOnce(meeting.clearDataChannelToken);
14099
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
14100
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
14101
+
14102
+ await meeting.clearMeetingData();
14103
+
14104
+ assert.notCalled(webex.internal.llm.disconnectLLM);
14105
+ // Shared data-channel auth tokens belong to the owner meeting's
14106
+ // live LLM session and must not be wiped by a non-owner
14107
+ // teardown, otherwise the owner's next reconnect would lose
14108
+ // its Data-Channel-Auth-Token.
14109
+ assert.notCalled(meeting.clearDataChannelToken);
14110
+ // Listeners owned by *this* Meeting instance must still be
14111
+ // removed so a leaving subordinate meeting stops receiving
14112
+ // relay/locus events from the shared singleton.
14113
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
14114
+ assert.calledWithExactly(
14115
+ webex.internal.llm.off,
14116
+ 'event:relay.event',
14117
+ meeting.processRelayEvent
14118
+ );
14119
+ assert.calledWithExactly(
14120
+ webex.internal.llm.off,
14121
+ 'event:locus.state_message',
14122
+ meeting.processLocusLLMEvent
14123
+ );
14124
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
14125
+ });
14126
+
14127
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
14128
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
14129
+
14130
+ await meeting.clearMeetingData();
14131
+
14132
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14133
+ code: 3050,
14134
+ reason: 'done (permanent)',
14135
+ });
14136
+ assert.calledOnce(meeting.clearDataChannelToken);
14137
+ });
14138
+
14139
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
14140
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
14141
+
14142
+ await meeting.clearMeetingData();
14143
+
14144
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
14145
+ code: 3050,
14146
+ reason: 'done (permanent)',
14147
+ });
14148
+ assert.calledOnce(meeting.clearDataChannelToken);
14149
+ });
13262
14150
  });
13263
14151
  });
13264
14152
  });
@@ -16045,4 +16933,4 @@ describe('plugin-meetings', () => {
16045
16933
  assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId);
16046
16934
  });
16047
16935
  });
16048
- });
16936
+ });