@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.70

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 (178) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +30 -7
  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 +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +298 -87
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/types.js +19 -0
  40. package/dist/locus-info/types.js.map +1 -1
  41. package/dist/media/index.js +3 -1
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/properties.js +1 -0
  44. package/dist/media/properties.js.map +1 -1
  45. package/dist/meeting/in-meeting-actions.js +3 -1
  46. package/dist/meeting/in-meeting-actions.js.map +1 -1
  47. package/dist/meeting/index.js +1046 -689
  48. package/dist/meeting/index.js.map +1 -1
  49. package/dist/meeting/muteState.js +10 -1
  50. package/dist/meeting/muteState.js.map +1 -1
  51. package/dist/meeting/request.js +5 -2
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/util.js +20 -2
  54. package/dist/meeting/util.js.map +1 -1
  55. package/dist/meeting-info/meeting-info-v2.js +2 -2
  56. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  57. package/dist/meetings/index.js +231 -78
  58. package/dist/meetings/index.js.map +1 -1
  59. package/dist/meetings/meetings.types.js +6 -1
  60. package/dist/meetings/meetings.types.js.map +1 -1
  61. package/dist/meetings/request.js +39 -0
  62. package/dist/meetings/request.js.map +1 -1
  63. package/dist/meetings/util.js +79 -5
  64. package/dist/meetings/util.js.map +1 -1
  65. package/dist/member/index.js +10 -0
  66. package/dist/member/index.js.map +1 -1
  67. package/dist/member/types.js.map +1 -1
  68. package/dist/member/util.js +3 -0
  69. package/dist/member/util.js.map +1 -1
  70. package/dist/metrics/constants.js +4 -1
  71. package/dist/metrics/constants.js.map +1 -1
  72. package/dist/multistream/codec/constants.js +63 -0
  73. package/dist/multistream/codec/constants.js.map +1 -0
  74. package/dist/multistream/mediaRequestManager.js +62 -15
  75. package/dist/multistream/mediaRequestManager.js.map +1 -1
  76. package/dist/multistream/receiveSlot.js +9 -0
  77. package/dist/multistream/receiveSlot.js.map +1 -1
  78. package/dist/reactions/reactions.type.js.map +1 -1
  79. package/dist/recording-controller/index.js +1 -3
  80. package/dist/recording-controller/index.js.map +1 -1
  81. package/dist/types/config.d.ts +2 -0
  82. package/dist/types/constants.d.ts +9 -1
  83. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  84. package/dist/types/controls-options-manager/index.d.ts +10 -0
  85. package/dist/types/hashTree/constants.d.ts +2 -0
  86. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  87. package/dist/types/hashTree/utils.d.ts +18 -0
  88. package/dist/types/index.d.ts +3 -0
  89. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  90. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  91. package/dist/types/locus-info/index.d.ts +50 -6
  92. package/dist/types/locus-info/types.d.ts +21 -1
  93. package/dist/types/media/properties.d.ts +1 -0
  94. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  95. package/dist/types/meeting/index.d.ts +78 -5
  96. package/dist/types/meeting/request.d.ts +1 -0
  97. package/dist/types/meeting/util.d.ts +8 -0
  98. package/dist/types/meetings/index.d.ts +30 -2
  99. package/dist/types/meetings/meetings.types.d.ts +15 -0
  100. package/dist/types/meetings/request.d.ts +14 -0
  101. package/dist/types/member/index.d.ts +1 -0
  102. package/dist/types/member/types.d.ts +1 -0
  103. package/dist/types/member/util.d.ts +1 -0
  104. package/dist/types/metrics/constants.d.ts +3 -0
  105. package/dist/types/multistream/codec/constants.d.ts +7 -0
  106. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  107. package/dist/types/reactions/reactions.type.d.ts +3 -0
  108. package/dist/webinar/index.js +305 -159
  109. package/dist/webinar/index.js.map +1 -1
  110. package/package.json +22 -22
  111. package/src/aiEnableRequest/index.ts +16 -0
  112. package/src/breakouts/breakout.ts +3 -1
  113. package/src/breakouts/index.ts +31 -0
  114. package/src/config.ts +2 -0
  115. package/src/constants.ts +13 -2
  116. package/src/controls-options-manager/constants.ts +14 -1
  117. package/src/controls-options-manager/index.ts +47 -24
  118. package/src/controls-options-manager/util.ts +81 -1
  119. package/src/hashTree/constants.ts +16 -0
  120. package/src/hashTree/hashTreeParser.ts +580 -196
  121. package/src/hashTree/utils.ts +36 -0
  122. package/src/index.ts +6 -0
  123. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  124. package/src/interceptors/locusRetry.ts +25 -4
  125. package/src/interpretation/index.ts +27 -9
  126. package/src/interpretation/interpretation.types.ts +11 -0
  127. package/src/locus-info/controlsUtils.ts +3 -1
  128. package/src/locus-info/index.ts +293 -97
  129. package/src/locus-info/types.ts +25 -1
  130. package/src/media/index.ts +3 -0
  131. package/src/media/properties.ts +1 -0
  132. package/src/meeting/in-meeting-actions.ts +4 -0
  133. package/src/meeting/index.ts +386 -48
  134. package/src/meeting/muteState.ts +10 -1
  135. package/src/meeting/request.ts +11 -0
  136. package/src/meeting/util.ts +21 -2
  137. package/src/meeting-info/meeting-info-v2.ts +4 -2
  138. package/src/meetings/index.ts +134 -44
  139. package/src/meetings/meetings.types.ts +19 -0
  140. package/src/meetings/request.ts +43 -0
  141. package/src/meetings/util.ts +97 -1
  142. package/src/member/index.ts +10 -0
  143. package/src/member/types.ts +1 -0
  144. package/src/member/util.ts +3 -0
  145. package/src/metrics/constants.ts +3 -0
  146. package/src/multistream/codec/constants.ts +58 -0
  147. package/src/multistream/mediaRequestManager.ts +119 -28
  148. package/src/multistream/receiveSlot.ts +18 -0
  149. package/src/reactions/reactions.type.ts +3 -0
  150. package/src/recording-controller/index.ts +1 -2
  151. package/src/webinar/index.ts +214 -36
  152. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  153. package/test/unit/spec/breakouts/breakout.ts +9 -3
  154. package/test/unit/spec/breakouts/index.ts +49 -0
  155. package/test/unit/spec/controls-options-manager/index.js +140 -29
  156. package/test/unit/spec/controls-options-manager/util.js +165 -0
  157. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  158. package/test/unit/spec/hashTree/utils.ts +125 -1
  159. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  160. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  161. package/test/unit/spec/interpretation/index.ts +26 -4
  162. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  163. package/test/unit/spec/locus-info/index.js +487 -81
  164. package/test/unit/spec/media/index.ts +31 -0
  165. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  166. package/test/unit/spec/meeting/index.js +1240 -37
  167. package/test/unit/spec/meeting/muteState.js +81 -0
  168. package/test/unit/spec/meeting/request.js +12 -0
  169. package/test/unit/spec/meeting/utils.js +33 -0
  170. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  171. package/test/unit/spec/meetings/index.js +360 -10
  172. package/test/unit/spec/meetings/request.js +141 -0
  173. package/test/unit/spec/meetings/utils.js +189 -0
  174. package/test/unit/spec/member/index.js +7 -0
  175. package/test/unit/spec/member/util.js +24 -0
  176. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  177. package/test/unit/spec/recording-controller/index.js +9 -8
  178. package/test/unit/spec/webinar/index.ts +329 -28
@@ -35,6 +35,7 @@ describe('plugin-meetings', () => {
35
35
  beforeEach(() => {
36
36
  request = {
37
37
  request: sinon.stub().returns(Promise.resolve()),
38
+ locusDeltaRequest: sinon.stub().returns(Promise.resolve()),
38
39
  };
39
40
 
40
41
  controller = new RecordingController(request);
@@ -69,13 +70,13 @@ describe('plugin-meetings', () => {
69
70
 
70
71
  const result = controller.startRecording();
71
72
 
72
- assert.calledWith(request.request, {
73
+ assert.calledWith(request.locusDeltaRequest, {
73
74
  uri: `${locusUrl}/controls`,
74
75
  body: {record: {recording: true, paused: false}},
75
76
  method: HTTP_VERBS.PATCH,
76
77
  });
77
78
 
78
- assert.deepEqual(result, request.request.firstCall.returnValue);
79
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
79
80
  });
80
81
  });
81
82
 
@@ -103,13 +104,13 @@ describe('plugin-meetings', () => {
103
104
 
104
105
  const result = controller.stopRecording();
105
106
 
106
- assert.calledWith(request.request, {
107
+ assert.calledWith(request.locusDeltaRequest, {
107
108
  uri: `${locusUrl}/controls`,
108
109
  body: {record: {recording: false, paused: false}},
109
110
  method: HTTP_VERBS.PATCH,
110
111
  });
111
112
 
112
- assert.deepEqual(result, request.request.firstCall.returnValue);
113
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
113
114
  });
114
115
  });
115
116
 
@@ -139,13 +140,13 @@ describe('plugin-meetings', () => {
139
140
 
140
141
  const result = controller.pauseRecording();
141
142
 
142
- assert.calledWith(request.request, {
143
+ assert.calledWith(request.locusDeltaRequest, {
143
144
  uri: `${locusUrl}/controls`,
144
145
  body: {record: {recording: true, paused: true}},
145
146
  method: HTTP_VERBS.PATCH,
146
147
  });
147
148
 
148
- assert.deepEqual(result, request.request.firstCall.returnValue);
149
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
149
150
  });
150
151
  });
151
152
 
@@ -176,13 +177,13 @@ describe('plugin-meetings', () => {
176
177
 
177
178
  const result = controller.resumeRecording();
178
179
 
179
- assert.calledWith(request.request, {
180
+ assert.calledWith(request.locusDeltaRequest, {
180
181
  uri: `${locusUrl}/controls`,
181
182
  body: {record: {recording: true, paused: false}},
182
183
  method: HTTP_VERBS.PATCH,
183
184
  });
184
185
 
185
- assert.deepEqual(result, request.request.firstCall.returnValue);
186
+ assert.deepEqual(result, request.locusDeltaRequest.firstCall.returnValue);
186
187
  });
187
188
  });
188
189
  });
@@ -5,7 +5,9 @@ import MockWebex from '@webex/test-helper-mock-webex';
5
5
  import uuid from 'uuid';
6
6
  import sinon from 'sinon';
7
7
  import {DataChannelTokenType} from '@webex/internal-plugin-llm';
8
- import {LLM_PRACTICE_SESSION, SHARE_STATUS} from '@webex/plugin-meetings/src/constants';
8
+ import {LLM_PRACTICE_SESSION, LOCUS_LLM_EVENT, SHARE_STATUS} from '@webex/plugin-meetings/src/constants';
9
+
10
+ const PRACTICE_SESSION_KEY = LLM_PRACTICE_SESSION || DataChannelTokenType.PracticeSession;
9
11
 
10
12
  describe('plugin-meetings', () => {
11
13
  describe('Webinar', () => {
@@ -33,6 +35,17 @@ describe('plugin-meetings', () => {
33
35
  webex.internal.llm = {
34
36
  getDatachannelToken: sinon.stub().returns(undefined),
35
37
  setDatachannelToken: sinon.stub(),
38
+ setRefreshHandler: sinon.stub(),
39
+ getOwnerMeetingId: sinon.stub().returns(undefined),
40
+ resolveSessionOwnership: sinon.stub().callsFake((ownerMeetingId, sessionId) => {
41
+ const currentOwner = webex.internal.llm.getOwnerMeetingId(sessionId);
42
+
43
+ return {
44
+ currentOwner,
45
+ isOwner: !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId,
46
+ };
47
+ }),
48
+ setOwnerMeetingId: sinon.stub(),
36
49
  isDataChannelTokenEnabled: sinon.stub().resolves(false),
37
50
  isConnected: sinon.stub().returns(false),
38
51
  disconnectLLM: sinon.stub().resolves(),
@@ -176,6 +189,42 @@ describe('plugin-meetings', () => {
176
189
  });
177
190
  });
178
191
 
192
+ describe('#getValidatedWebinarMeeting', () => {
193
+ it('returns the meeting when its locusUrl matches the webinar locusUrl', () => {
194
+ const meeting = {locusUrl: 'locusUrl'};
195
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
196
+ webinar.locusUrl = 'locusUrl';
197
+
198
+ assert.equal(webinar.getValidatedWebinarMeeting(), meeting);
199
+ });
200
+
201
+ it('returns undefined and warns when the resolved meeting locusUrl does not match', () => {
202
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
203
+ const meeting = {locusUrl: 'other-locus-url'};
204
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
205
+ webinar.locusUrl = 'locusUrl';
206
+
207
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
208
+ assert.calledOnce(warnStub);
209
+ });
210
+
211
+ it('returns undefined when no meeting is resolved', () => {
212
+ webex.meetings.getMeetingByType = sinon.stub().returns(undefined);
213
+
214
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
215
+ });
216
+
217
+ it('returns undefined and warns when webinar locusUrl is not yet initialized', () => {
218
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
219
+ const meeting = {locusUrl: 'some-url'};
220
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
221
+ webinar.locusUrl = undefined;
222
+
223
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
224
+ assert.calledOnce(warnStub);
225
+ });
226
+ });
227
+
179
228
  describe('#cleanUp', () => {
180
229
  it('delegates to cleanupPSDataChannel', () => {
181
230
  const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
@@ -187,29 +236,119 @@ describe('plugin-meetings', () => {
187
236
  });
188
237
 
189
238
  describe('#cleanupPSDataChannel', () => {
190
- let meeting;
239
+ let relayListener;
191
240
 
192
241
  beforeEach(() => {
193
- meeting = {
194
- processRelayEvent: sinon.stub(),
195
- };
242
+ webinar.meetingId = 'meeting-id';
243
+ relayListener = sinon.stub();
244
+ webinar.llmListeners = {relay: relayListener, locusLLM: null};
245
+ });
196
246
 
197
- webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
247
+ it('disconnects the practice session channel and removes the tracked relay listener', async () => {
248
+ await webinar.cleanupPSDataChannel();
249
+
250
+ assert.calledOnceWithExactly(
251
+ webex.internal.llm.disconnectLLM,
252
+ {code: 3050, reason: 'done (permanent)'},
253
+ PRACTICE_SESSION_KEY,
254
+ webinar.meetingId
255
+ );
256
+ assert.calledWithExactly(
257
+ webex.internal.llm.off,
258
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
259
+ relayListener
260
+ );
261
+ assert.isNull(webinar.llmListeners.relay);
198
262
  });
199
263
 
200
- it('disconnects the practice session channel and removes the relay listener', async () => {
264
+ it('skips disconnect when practice-session owner is another meeting', async () => {
265
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
266
+ webex.internal.llm.disconnectLLM.resolves(false);
267
+
201
268
  await webinar.cleanupPSDataChannel();
202
269
 
203
270
  assert.calledOnceWithExactly(
204
271
  webex.internal.llm.disconnectLLM,
205
272
  {code: 3050, reason: 'done (permanent)'},
206
- LLM_PRACTICE_SESSION
273
+ PRACTICE_SESSION_KEY,
274
+ webinar.meetingId
207
275
  );
276
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
208
277
  assert.calledOnceWithExactly(
209
278
  webex.internal.llm.off,
210
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
211
- meeting.processRelayEvent
279
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
280
+ relayListener
281
+ );
282
+ });
283
+
284
+ it('skips relay listener removal when no listener has been tracked', async () => {
285
+ webinar.llmListeners.relay = null;
286
+
287
+ await webinar.cleanupPSDataChannel();
288
+
289
+ const relayOffCalls = webex.internal.llm.off.args.filter(
290
+ ([event]) => event === `event:relay.event:${PRACTICE_SESSION_KEY}`
212
291
  );
292
+ assert.equal(relayOffCalls.length, 0);
293
+ });
294
+
295
+ it('removes tracked relay listener even when disconnect throws', async () => {
296
+ const disconnectError = new Error('disconnect failed');
297
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
298
+
299
+ let caughtError;
300
+
301
+ try {
302
+ await webinar.cleanupPSDataChannel();
303
+ } catch (error) {
304
+ caughtError = error;
305
+ }
306
+
307
+ assert.equal(caughtError, disconnectError);
308
+ assert.calledOnceWithExactly(
309
+ webex.internal.llm.setOwnerMeetingId,
310
+ undefined,
311
+ PRACTICE_SESSION_KEY
312
+ );
313
+ assert.calledOnceWithExactly(
314
+ webex.internal.llm.off,
315
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
316
+ relayListener
317
+ );
318
+ assert.notOk(webinar._practiceSessionRelayListener);
319
+ });
320
+
321
+ it('disconnects and removes the tracked locusLLM listener', async () => {
322
+ const locusLLMListener = sinon.stub();
323
+ webinar.llmListeners.locusLLM = locusLLMListener;
324
+
325
+ await webinar.cleanupPSDataChannel();
326
+
327
+ assert.calledWithExactly(
328
+ webex.internal.llm.off,
329
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
330
+ locusLLMListener
331
+ );
332
+ assert.isNull(webinar.llmListeners.locusLLM);
333
+ });
334
+
335
+ it('skips locusLLM listener removal when no listener has been tracked', async () => {
336
+ webinar.llmListeners.locusLLM = null;
337
+
338
+ await webinar.cleanupPSDataChannel();
339
+
340
+ const locusLLMOffCalls = webex.internal.llm.off.args.filter(
341
+ ([event]) => event === `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`
342
+ );
343
+ assert.equal(locusLLMOffCalls.length, 0);
344
+ });
345
+
346
+ it('does not consult the meeting collection during cleanup', async () => {
347
+ webex.meetings.getMeetingByType = sinon.stub();
348
+
349
+ await webinar.cleanupPSDataChannel();
350
+
351
+ assert.notCalled(webex.meetings.getMeetingByType);
213
352
  });
214
353
 
215
354
  it('removes a pending online listener if one exists', async () => {
@@ -236,12 +375,18 @@ describe('plugin-meetings', () => {
236
375
  describe('#updatePSDataChannel', () => {
237
376
  let meeting;
238
377
  let processRelayEvent;
378
+ let processLocusLLMEvent;
239
379
 
240
380
  beforeEach(() => {
381
+ webinar.meetingId = 'meeting-id';
241
382
  processRelayEvent = sinon.stub();
383
+ processLocusLLMEvent = sinon.stub();
242
384
  meeting = {
385
+ id: 'meeting-id',
386
+ locusUrl: 'locusUrl',
243
387
  isJoined: sinon.stub().returns(true),
244
388
  processRelayEvent,
389
+ processLocusLLMEvent,
245
390
  locusInfo: {
246
391
  url: 'locus-url',
247
392
  info: {practiceSessionDatachannelUrl: 'dc-url'},
@@ -252,7 +397,7 @@ describe('plugin-meetings', () => {
252
397
 
253
398
  // Default session is connected by default; practice session is not
254
399
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
255
- return sessionId !== LLM_PRACTICE_SESSION;
400
+ return sessionId !== PRACTICE_SESSION_KEY;
256
401
  });
257
402
 
258
403
  // Token is pre-saved into LLM by saveDataChannelToken
@@ -288,14 +433,15 @@ describe('plugin-meetings', () => {
288
433
  assert.calledWithExactly(
289
434
  webex.internal.llm.setDatachannelToken,
290
435
  'ps-token-from-refresh',
291
- DataChannelTokenType.PracticeSession
436
+ DataChannelTokenType.PracticeSession,
437
+ 'meeting-id'
292
438
  );
293
439
  assert.calledWith(
294
440
  webex.internal.llm.registerAndConnect,
295
441
  'locus-url',
296
442
  'dc-url',
297
443
  'ps-token-from-refresh',
298
- LLM_PRACTICE_SESSION
444
+ PRACTICE_SESSION_KEY
299
445
  );
300
446
  });
301
447
 
@@ -355,6 +501,8 @@ describe('plugin-meetings', () => {
355
501
  const result = await webinar.updatePSDataChannel();
356
502
 
357
503
  assert.isUndefined(result);
504
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
505
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
358
506
  assert.notCalled(webex.internal.llm.registerAndConnect);
359
507
  });
360
508
 
@@ -375,12 +523,17 @@ describe('plugin-meetings', () => {
375
523
  const result = await webinar.updatePSDataChannel();
376
524
 
377
525
  assert.calledOnce(webex.internal.llm.registerAndConnect);
526
+ assert.calledWithExactly(
527
+ webex.internal.llm.setOwnerMeetingId,
528
+ 'meeting-id',
529
+ PRACTICE_SESSION_KEY
530
+ );
378
531
  assert.calledWith(
379
532
  webex.internal.llm.registerAndConnect,
380
533
  'locus-url',
381
534
  'dc-url',
382
535
  'ps-token',
383
- LLM_PRACTICE_SESSION
536
+ PRACTICE_SESSION_KEY
384
537
  );
385
538
  assert.calledOnceWithExactly(webex.internal.voicea.announce);
386
539
  assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
@@ -396,7 +549,8 @@ describe('plugin-meetings', () => {
396
549
 
397
550
  assert.calledWithExactly(
398
551
  webex.internal.llm.getDatachannelToken,
399
- DataChannelTokenType.PracticeSession
552
+ DataChannelTokenType.PracticeSession,
553
+ webinar.meetingId
400
554
  );
401
555
  assert.notCalled(webex.internal.llm.setDatachannelToken);
402
556
  assert.calledWith(
@@ -404,7 +558,7 @@ describe('plugin-meetings', () => {
404
558
  'locus-url',
405
559
  'dc-url',
406
560
  'cached-token',
407
- LLM_PRACTICE_SESSION
561
+ PRACTICE_SESSION_KEY
408
562
  );
409
563
  });
410
564
 
@@ -418,19 +572,55 @@ describe('plugin-meetings', () => {
418
572
  assert.calledOnce(webex.internal.llm.registerAndConnect);
419
573
  });
420
574
 
421
- it('rebinds relay listener after successful connect', async () => {
575
+ it('tracks and binds the relay listener after successful connect', async () => {
422
576
  await webinar.updatePSDataChannel();
423
577
 
578
+ // Stores the exact listener reference for deterministic cleanup
579
+ assert.equal(webinar.llmListeners.relay, processRelayEvent);
424
580
  assert.calledWith(
425
- webex.internal.llm.off,
426
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
581
+ webex.internal.llm.on,
582
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
427
583
  processRelayEvent
428
584
  );
585
+ });
586
+
587
+ it('removes a previously tracked relay listener before re-binding on reconnect', async () => {
588
+ const previousListener = sinon.stub();
589
+ webinar.llmListeners = {relay: previousListener, locusLLM: null};
590
+
591
+ await webinar.updatePSDataChannel();
592
+
593
+ assert.calledWith(
594
+ webex.internal.llm.off,
595
+ `event:relay.event:${PRACTICE_SESSION_KEY}`,
596
+ previousListener
597
+ );
598
+ assert.equal(webinar.llmListeners.relay, processRelayEvent);
599
+ });
600
+
601
+ it('tracks and binds the locusLLM listener after successful connect', async () => {
602
+ await webinar.updatePSDataChannel();
603
+
604
+ assert.equal(webinar.llmListeners.locusLLM, processLocusLLMEvent);
429
605
  assert.calledWith(
430
606
  webex.internal.llm.on,
431
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
432
- processRelayEvent
607
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
608
+ processLocusLLMEvent
609
+ );
610
+ });
611
+
612
+ it('removes a previously tracked locusLLM listener before re-binding on reconnect', async () => {
613
+ const previousListener = sinon.stub();
614
+ webinar.llmListeners = {relay: null, locusLLM: previousListener};
615
+
616
+ await webinar.updatePSDataChannel();
617
+
618
+ assert.calledWith(
619
+ webex.internal.llm.off,
620
+ `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
621
+ previousListener
433
622
  );
623
+ assert.equal(webinar.llmListeners.locusLLM, processLocusLLMEvent);
434
624
  });
435
625
 
436
626
  it('subscribes to transcription when caption intent is enabled', async () => {
@@ -460,6 +650,8 @@ describe('plugin-meetings', () => {
460
650
  // Should register an 'online' listener but NOT call registerAndConnect yet
461
651
  assert.calledWith(webex.internal.llm.on, 'online', sinon.match.func);
462
652
  assert.notCalled(webex.internal.llm.registerAndConnect);
653
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
654
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
463
655
  // Should store the pending listener
464
656
  assert.isNotNull(webinar._pendingOnlineListener);
465
657
  });
@@ -491,7 +683,7 @@ describe('plugin-meetings', () => {
491
683
 
492
684
  // Now simulate default session coming online
493
685
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
494
- return sessionId !== LLM_PRACTICE_SESSION;
686
+ return sessionId !== PRACTICE_SESSION_KEY;
495
687
  });
496
688
 
497
689
  // Fire the captured listener
@@ -518,7 +710,7 @@ describe('plugin-meetings', () => {
518
710
 
519
711
  // Now default session comes online
520
712
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
521
- return sessionId !== LLM_PRACTICE_SESSION;
713
+ return sessionId !== PRACTICE_SESSION_KEY;
522
714
  });
523
715
 
524
716
  // Fire the listener — re-invokes updatePSDataChannel which will see isPracticeSession = false
@@ -531,7 +723,7 @@ describe('plugin-meetings', () => {
531
723
  it('proceeds immediately when default session is already connected', async () => {
532
724
  // Default session already connected, practice session not
533
725
  webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
534
- return sessionId !== LLM_PRACTICE_SESSION;
726
+ return sessionId !== PRACTICE_SESSION_KEY;
535
727
  });
536
728
 
537
729
  const result = await webinar.updatePSDataChannel();
@@ -543,6 +735,115 @@ describe('plugin-meetings', () => {
543
735
  assert.calledOnce(webex.internal.llm.registerAndConnect);
544
736
  assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
545
737
  });
738
+
739
+ it('does not override practice refresh handler or reconnect when owned by another meeting', async () => {
740
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
741
+ webex.internal.llm.isConnected = sinon.stub().callsFake((sessionId) => {
742
+ return sessionId !== undefined;
743
+ });
744
+
745
+ const result = await webinar.updatePSDataChannel();
746
+
747
+ assert.isUndefined(result);
748
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
749
+ assert.notCalled(webex.internal.llm.registerAndConnect);
750
+ });
751
+
752
+ it('does not reconnect when practice session is disconnected but owned by another meeting', async () => {
753
+ webex.internal.llm.getOwnerMeetingId.returns('other-meeting-id');
754
+ webex.internal.llm.isConnected = sinon.stub().returns(false);
755
+
756
+ const result = await webinar.updatePSDataChannel();
757
+
758
+ assert.isUndefined(result);
759
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
760
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
761
+ assert.notCalled(webex.internal.llm.registerAndConnect);
762
+ });
763
+
764
+ it('does not write owner or connect if ownership changes before pre-connect owner write', async () => {
765
+ let ownerMeetingId = 'meeting-id';
766
+
767
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
768
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
769
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
770
+
771
+ let resolveRefresh;
772
+ meeting.refreshDataChannelToken = sinon.stub().returns(
773
+ new Promise((resolve) => {
774
+ resolveRefresh = resolve;
775
+ })
776
+ );
777
+
778
+ const updatePromise = webinar.updatePSDataChannel();
779
+
780
+ ownerMeetingId = 'other-meeting-id';
781
+ resolveRefresh({
782
+ body: {
783
+ datachannelToken: 'ps-token-from-refresh',
784
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
785
+ },
786
+ });
787
+
788
+ const result = await updatePromise;
789
+
790
+ assert.isUndefined(result);
791
+ assert.notCalled(webex.internal.llm.setRefreshHandler);
792
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
793
+ assert.notCalled(webex.internal.llm.registerAndConnect);
794
+ });
795
+
796
+ it('does not overwrite owner after connect when ownership changed during registerAndConnect', async () => {
797
+ let ownerMeetingId = 'meeting-id';
798
+
799
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
800
+ webex.internal.llm.registerAndConnect = sinon.stub().callsFake(async () => {
801
+ ownerMeetingId = 'other-meeting-id';
802
+
803
+ return 'REGISTER_AND_CONNECT_RESULT';
804
+ });
805
+
806
+ const result = await webinar.updatePSDataChannel();
807
+
808
+ assert.equal(result, 'REGISTER_AND_CONNECT_RESULT');
809
+ assert.calledOnce(webex.internal.llm.setOwnerMeetingId);
810
+ assert.calledWithExactly(
811
+ webex.internal.llm.setOwnerMeetingId,
812
+ 'meeting-id',
813
+ PRACTICE_SESSION_KEY
814
+ );
815
+ });
816
+
817
+ it('clears pre-claimed owner when registerAndConnect rejects', async () => {
818
+ const registerError = new Error('register failed');
819
+ let ownerMeetingId = 'meeting-id';
820
+
821
+ webex.internal.llm.getOwnerMeetingId.callsFake(() => ownerMeetingId);
822
+ webex.internal.llm.setOwnerMeetingId.callsFake((id) => {
823
+ ownerMeetingId = id;
824
+ });
825
+ webex.internal.llm.registerAndConnect = sinon.stub().rejects(registerError);
826
+
827
+ try {
828
+ await webinar.updatePSDataChannel();
829
+ assert.fail('Expected updatePSDataChannel to reject when registerAndConnect fails');
830
+ } catch (error) {
831
+ assert.equal(error, registerError);
832
+ }
833
+
834
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
835
+ assert.calledWithExactly(
836
+ webex.internal.llm.setOwnerMeetingId.firstCall,
837
+ 'meeting-id',
838
+ PRACTICE_SESSION_KEY
839
+ );
840
+ assert.calledWithExactly(
841
+ webex.internal.llm.setOwnerMeetingId.secondCall,
842
+ undefined,
843
+ PRACTICE_SESSION_KEY
844
+ );
845
+ assert.isUndefined(ownerMeetingId);
846
+ });
546
847
  });
547
848
 
548
849
  describe('#updateStatusByRole', () => {
@@ -551,7 +852,7 @@ describe('plugin-meetings', () => {
551
852
  updateMediaShares = sinon.stub()
552
853
  webinar.webex.meetings = {
553
854
  getMeetingByType: sinon.stub().returns({
554
- id: 'meeting-id',
855
+ id: 'meeting-id', locusUrl: 'locusUrl',
555
856
  isJoined: sinon.stub().returns(false),
556
857
  updateLLMConnection: sinon.stub(),
557
858
  shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
@@ -606,7 +907,7 @@ describe('plugin-meetings', () => {
606
907
 
607
908
  webinar.webex.meetings = {
608
909
  getMeetingByType: sinon.stub().returns({
609
- id: 'meeting-id',
910
+ id: 'meeting-id', locusUrl: 'locusUrl',
610
911
  isJoined: sinon.stub().returns(false),
611
912
  updateLLMConnection: sinon.stub(),
612
913
  shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
@@ -1034,7 +1335,7 @@ describe('plugin-meetings', () => {
1034
1335
  // @ts-ignore
1035
1336
  webinar.webex.meetings = {
1036
1337
  getMeetingByType: sinon.stub().returns({
1037
- id: 'meeting-id',
1338
+ id: 'meeting-id', locusUrl: 'locusUrl',
1038
1339
  locusInfo: {
1039
1340
  links:{
1040
1341
  resources: {
@@ -1051,7 +1352,7 @@ describe('plugin-meetings', () => {
1051
1352
  it('throws an error if attendeeSearchUrl is not available', async () => {
1052
1353
  webinar.webex.meetings = {
1053
1354
  getMeetingByType: sinon.stub().returns({
1054
- id: 'meeting-id',
1355
+ id: 'meeting-id', locusUrl: 'locusUrl',
1055
1356
  locusInfo: {
1056
1357
  links:{
1057
1358
  resources: {