@webex/plugin-meetings 3.12.0-next.44 → 3.12.0-next.46

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 (33) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/controls-options-manager/index.js +17 -5
  5. package/dist/controls-options-manager/index.js.map +1 -1
  6. package/dist/interpretation/index.js +10 -1
  7. package/dist/interpretation/index.js.map +1 -1
  8. package/dist/interpretation/siLanguage.js +1 -1
  9. package/dist/meeting/index.js +94 -20
  10. package/dist/meeting/index.js.map +1 -1
  11. package/dist/meeting/util.js +15 -2
  12. package/dist/meeting/util.js.map +1 -1
  13. package/dist/recording-controller/index.js +1 -3
  14. package/dist/recording-controller/index.js.map +1 -1
  15. package/dist/types/controls-options-manager/index.d.ts +10 -0
  16. package/dist/types/meeting/index.d.ts +6 -0
  17. package/dist/types/meeting/util.d.ts +7 -0
  18. package/dist/webinar/index.js +68 -17
  19. package/dist/webinar/index.js.map +1 -1
  20. package/package.json +3 -3
  21. package/src/controls-options-manager/index.ts +22 -6
  22. package/src/interpretation/index.ts +25 -8
  23. package/src/meeting/index.ts +79 -6
  24. package/src/meeting/util.ts +16 -2
  25. package/src/recording-controller/index.ts +1 -2
  26. package/src/webinar/index.ts +88 -21
  27. package/test/unit/spec/controls-options-manager/index.js +35 -32
  28. package/test/unit/spec/interpretation/index.ts +26 -4
  29. package/test/unit/spec/meeting/index.js +183 -0
  30. package/test/unit/spec/meeting/muteState.js +3 -0
  31. package/test/unit/spec/meeting/utils.js +25 -0
  32. package/test/unit/spec/recording-controller/index.js +9 -8
  33. package/test/unit/spec/webinar/index.ts +81 -16
@@ -9,6 +9,7 @@ describe('plugin-meetings', () => {
9
9
  describe('SimultaneousInterpretation', () => {
10
10
  let webex;
11
11
  let interpretation;
12
+ let mockMeeting;
12
13
 
13
14
  beforeEach(() => {
14
15
  // @ts-ignore
@@ -17,8 +18,17 @@ describe('plugin-meetings', () => {
17
18
  interpretation = new SimultaneousInterpretation({}, {parent: webex});
18
19
  interpretation.locusUrl = 'locusUrl';
19
20
  webex.request = sinon.stub().returns(Promise.resolve('REQUEST_RETURN_VALUE'));
20
- webex.meetings = {};
21
- webex.meetings.getMeetingByType = sinon.stub();
21
+ mockMeeting = {
22
+ locusInfo: {
23
+ handleLocusAPIResponse: sinon.stub(),
24
+ },
25
+ };
26
+ webex.meetings = {
27
+ getMeetingByType: sinon.stub(),
28
+ meetingCollection: {
29
+ getByKey: sinon.stub().returns(mockMeeting),
30
+ },
31
+ };
22
32
  });
23
33
 
24
34
  describe('#initialize', () => {
@@ -316,7 +326,8 @@ describe('plugin-meetings', () => {
316
326
  order : 0,
317
327
  isActive : true
318
328
  },];
319
- webex.request.returns(Promise.resolve({}));
329
+ const mockResponse = {body: {locus: {url: 'locusUrl'}}};
330
+ webex.request.returns(Promise.resolve(mockResponse));
320
331
 
321
332
  await interpretation.updateInterpreters(sampleData);
322
333
  assert.calledOnceWithExactly(webex.request, {
@@ -328,6 +339,11 @@ describe('plugin-meetings', () => {
328
339
  },
329
340
  },
330
341
  });
342
+ assert.calledOnceWithExactly(
343
+ mockMeeting.locusInfo.handleLocusAPIResponse,
344
+ mockMeeting,
345
+ mockResponse.body
346
+ );
331
347
  });
332
348
 
333
349
  it('rejects with error', async () => {
@@ -354,7 +370,8 @@ describe('plugin-meetings', () => {
354
370
  order: 0,
355
371
  selfParticipantId: '123',
356
372
  });
357
- webex.request.returns(Promise.resolve({}));
373
+ const mockResponse = {body: {locus: {url: 'locusUrl'}}};
374
+ webex.request.returns(Promise.resolve(mockResponse));
358
375
 
359
376
  await interpretation.changeDirection();
360
377
  assert.calledOnceWithExactly(webex.request, {
@@ -369,6 +386,11 @@ describe('plugin-meetings', () => {
369
386
  },
370
387
  },
371
388
  });
389
+ assert.calledOnceWithExactly(
390
+ mockMeeting.locusInfo.handleLocusAPIResponse,
391
+ mockMeeting,
392
+ mockResponse.body
393
+ );
372
394
  });
373
395
 
374
396
  it('request rejects with error', async () => {
@@ -13703,6 +13703,131 @@ describe('plugin-meetings', () => {
13703
13703
  assert.notCalled(webex.internal.llm.setDatachannelToken);
13704
13704
  });
13705
13705
 
13706
+ describe('ownership tag', () => {
13707
+ beforeEach(() => {
13708
+ // Make the owner stub dynamic so setOwnerMeetingId() writes
13709
+ // propagate back to getOwnerMeetingId() reads. This mirrors the
13710
+ // real LLM singleton behavior so the finally-block release in
13711
+ // cleanupLLMConneciton is reflected in subsequent reads.
13712
+ webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined);
13713
+ webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => {
13714
+ webex.internal.llm.getOwnerMeetingId.returns(id);
13715
+ });
13716
+ });
13717
+
13718
+ it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => {
13719
+ meeting.joinedWith = {state: 'JOINED'};
13720
+ webex.internal.llm.isConnected.returns(true);
13721
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13722
+ // Locus/datachannel URL mismatch is the *normal* case when
13723
+ // another meeting owns the live socket -- each meeting has its
13724
+ // own locus URL. URL mismatch must NOT trigger a reclaim,
13725
+ // because doing so would tear down the owning meeting's healthy
13726
+ // LLM socket and break its data channel.
13727
+ webex.internal.llm.getLocusUrl.returns('owner-locus-url');
13728
+ webex.internal.llm.getDatachannelUrl.returns('owner-dc-url');
13729
+ meeting.locusInfo = {
13730
+ url: 'a different url',
13731
+ info: {datachannelUrl: 'a different datachannel url'},
13732
+ self: {},
13733
+ };
13734
+
13735
+ const result = await meeting.updateLLMConnection();
13736
+
13737
+ assert.equal(result, undefined);
13738
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13739
+ assert.notCalled(webex.internal.llm.registerAndConnect);
13740
+ assert.notCalled(webex.internal.llm.setOwnerMeetingId);
13741
+ assert.notCalled(meeting.startLLMHealthCheckTimer);
13742
+ });
13743
+
13744
+
13745
+ it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => {
13746
+ meeting.joinedWith = {state: 'JOINED'};
13747
+ webex.internal.llm.isConnected.returns(true);
13748
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13749
+ webex.internal.llm.getLocusUrl.returns('a url');
13750
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13751
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13752
+ meeting.locusInfo = {
13753
+ url: 'a different url',
13754
+ info: {datachannelUrl: 'a datachannel url'},
13755
+ self: {},
13756
+ };
13757
+
13758
+ try {
13759
+ await meeting.updateLLMConnection();
13760
+ } catch (e) {
13761
+ /* updateLLMConnection may reject when cleanup throws */
13762
+ }
13763
+
13764
+ // The owner-eligible finally branch must release the tag so a
13765
+ // subsequent reconnect attempt from any meeting is not blocked.
13766
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined);
13767
+ });
13768
+
13769
+ it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => {
13770
+ meeting.joinedWith = {state: 'JOINED'};
13771
+ webex.internal.llm.isConnected.returns(true);
13772
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13773
+ webex.internal.llm.getLocusUrl.returns('a url');
13774
+ webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
13775
+ meeting.locusInfo = {
13776
+ url: 'a different url',
13777
+ info: {datachannelUrl: 'a datachannel url'},
13778
+ self: {},
13779
+ };
13780
+
13781
+ await meeting.updateLLMConnection();
13782
+
13783
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13784
+ code: 3050,
13785
+ reason: 'done (permanent)',
13786
+ });
13787
+ assert.calledWithExactly(
13788
+ webex.internal.llm.registerAndConnect,
13789
+ 'a different url',
13790
+ 'a datachannel url',
13791
+ undefined
13792
+ );
13793
+ // setOwnerMeetingId is called twice: first with undefined in
13794
+ // cleanupLLMConneciton's finally block (so a failed disconnect
13795
+ // cannot leave a stale owner), then with this meeting's id
13796
+ // after registerAndConnect resolves.
13797
+ assert.calledTwice(webex.internal.llm.setOwnerMeetingId);
13798
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined);
13799
+ assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id);
13800
+ });
13801
+
13802
+ it('claims ownership after successful registerAndConnect on initial connect', async () => {
13803
+ meeting.joinedWith = {state: 'JOINED'};
13804
+ webex.internal.llm.isConnected.returns(false);
13805
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
13806
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13807
+
13808
+ await meeting.updateLLMConnection();
13809
+
13810
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13811
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13812
+ });
13813
+
13814
+ it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => {
13815
+ // Defensive path: if the LLM reports not-connected but an old
13816
+ // ownerId is still present (e.g. race before a successful
13817
+ // connections.delete), this meeting can still claim a fresh
13818
+ // connection.
13819
+ meeting.joinedWith = {state: 'JOINED'};
13820
+ webex.internal.llm.isConnected.returns(false);
13821
+ webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id');
13822
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
13823
+
13824
+ await meeting.updateLLMConnection();
13825
+
13826
+ assert.calledOnce(webex.internal.llm.registerAndConnect);
13827
+ assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id);
13828
+ });
13829
+ });
13830
+
13706
13831
  describe('#clearMeetingData', () => {
13707
13832
  beforeEach(() => {
13708
13833
  webex.internal.llm.isConnected = sinon.stub().returns(true);
@@ -13763,6 +13888,64 @@ describe('plugin-meetings', () => {
13763
13888
  assert.notCalled(meeting.stopTranscription);
13764
13889
  assert.notCalled(meeting.annotation.deregisterEvents);
13765
13890
  });
13891
+
13892
+ describe('ownership tag', () => {
13893
+ beforeEach(() => {
13894
+ webex.internal.llm.getOwnerMeetingId = sinon.stub();
13895
+ });
13896
+
13897
+ it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => {
13898
+ webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id');
13899
+
13900
+ await meeting.clearMeetingData();
13901
+
13902
+ assert.notCalled(webex.internal.llm.disconnectLLM);
13903
+ // Shared data-channel auth tokens belong to the owner meeting's
13904
+ // live LLM session and must not be wiped by a non-owner
13905
+ // teardown, otherwise the owner's next reconnect would lose
13906
+ // its Data-Channel-Auth-Token.
13907
+ assert.notCalled(meeting.clearDataChannelToken);
13908
+ // Listeners owned by *this* Meeting instance must still be
13909
+ // removed so a leaving subordinate meeting stops receiving
13910
+ // relay/locus events from the shared singleton.
13911
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13912
+ assert.calledWithExactly(
13913
+ webex.internal.llm.off,
13914
+ 'event:relay.event',
13915
+ meeting.processRelayEvent
13916
+ );
13917
+ assert.calledWithExactly(
13918
+ webex.internal.llm.off,
13919
+ 'event:locus.state_message',
13920
+ meeting.processLocusLLMEvent
13921
+ );
13922
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13923
+ });
13924
+
13925
+ it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => {
13926
+ webex.internal.llm.getOwnerMeetingId.returns(meeting.id);
13927
+
13928
+ await meeting.clearMeetingData();
13929
+
13930
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13931
+ code: 3050,
13932
+ reason: 'done (permanent)',
13933
+ });
13934
+ assert.calledOnce(meeting.clearDataChannelToken);
13935
+ });
13936
+
13937
+ it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => {
13938
+ webex.internal.llm.getOwnerMeetingId.returns(undefined);
13939
+
13940
+ await meeting.clearMeetingData();
13941
+
13942
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13943
+ code: 3050,
13944
+ reason: 'done (permanent)',
13945
+ });
13946
+ assert.calledOnce(meeting.clearDataChannelToken);
13947
+ });
13948
+ });
13766
13949
  });
13767
13950
  });
13768
13951
 
@@ -11,6 +11,7 @@ describe('plugin-meetings', () => {
11
11
  let audio;
12
12
  let video;
13
13
  let originalRemoteUpdateAudioVideo;
14
+ let originalUpdateLocusFromApiResponse;
14
15
 
15
16
  const fakeLocusResponse = {body: {locus: {info: 'this is a fake locus'}}};
16
17
 
@@ -45,6 +46,7 @@ describe('plugin-meetings', () => {
45
46
  };
46
47
 
47
48
  originalRemoteUpdateAudioVideo = MeetingUtil.remoteUpdateAudioVideo;
49
+ originalUpdateLocusFromApiResponse = MeetingUtil.updateLocusFromApiResponse;
48
50
 
49
51
  MeetingUtil.remoteUpdateAudioVideo = sinon.stub().resolves(fakeLocusResponse);
50
52
  MeetingUtil.updateLocusFromApiResponse = sinon.stub();
@@ -57,6 +59,7 @@ describe('plugin-meetings', () => {
57
59
 
58
60
  afterEach(() => {
59
61
  MeetingUtil.remoteUpdateAudioVideo = originalRemoteUpdateAudioVideo;
62
+ MeetingUtil.updateLocusFromApiResponse = originalUpdateLocusFromApiResponse;
60
63
  });
61
64
 
62
65
  describe('mute state library', () => {
@@ -276,6 +276,31 @@ describe('plugin-meetings', () => {
276
276
  assert.notCalled(meeting.locusInfo.handleLocusAPIResponse);
277
277
  });
278
278
 
279
+ it('should call handleLocusAPIResponse when response body is an unwrapped LocusDTO', () => {
280
+ const meeting = {
281
+ locusInfo: {
282
+ handleLocusAPIResponse: sinon.stub(),
283
+ },
284
+ };
285
+
286
+ const originalResponse = {
287
+ body: {
288
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/some-id',
289
+ participants: [],
290
+ self: {},
291
+ },
292
+ };
293
+
294
+ const response = MeetingUtil.updateLocusFromApiResponse(meeting, originalResponse);
295
+
296
+ assert.deepEqual(response, originalResponse);
297
+ assert.calledOnceWithExactly(
298
+ meeting.locusInfo.handleLocusAPIResponse,
299
+ meeting,
300
+ originalResponse.body
301
+ );
302
+ });
303
+
279
304
  it('should work with an undefined meeting', () => {
280
305
  const originalResponse = {
281
306
  body: {
@@ -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
  });
@@ -176,6 +176,42 @@ describe('plugin-meetings', () => {
176
176
  });
177
177
  });
178
178
 
179
+ describe('#getValidatedWebinarMeeting', () => {
180
+ it('returns the meeting when its locusUrl matches the webinar locusUrl', () => {
181
+ const meeting = {locusUrl: 'locusUrl'};
182
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
183
+ webinar.locusUrl = 'locusUrl';
184
+
185
+ assert.equal(webinar.getValidatedWebinarMeeting(), meeting);
186
+ });
187
+
188
+ it('returns undefined and warns when the resolved meeting locusUrl does not match', () => {
189
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
190
+ const meeting = {locusUrl: 'other-locus-url'};
191
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
192
+ webinar.locusUrl = 'locusUrl';
193
+
194
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
195
+ assert.calledOnce(warnStub);
196
+ });
197
+
198
+ it('returns undefined when no meeting is resolved', () => {
199
+ webex.meetings.getMeetingByType = sinon.stub().returns(undefined);
200
+
201
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
202
+ });
203
+
204
+ it('returns undefined and warns when webinar locusUrl is not yet initialized', () => {
205
+ const warnStub = sinon.stub(LoggerProxy.logger, 'warn');
206
+ const meeting = {locusUrl: 'some-url'};
207
+ webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
208
+ webinar.locusUrl = undefined;
209
+
210
+ assert.isUndefined(webinar.getValidatedWebinarMeeting());
211
+ assert.calledOnce(warnStub);
212
+ });
213
+ });
214
+
179
215
  describe('#cleanUp', () => {
180
216
  it('delegates to cleanupPSDataChannel', () => {
181
217
  const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();
@@ -187,17 +223,14 @@ describe('plugin-meetings', () => {
187
223
  });
188
224
 
189
225
  describe('#cleanupPSDataChannel', () => {
190
- let meeting;
226
+ let relayListener;
191
227
 
192
228
  beforeEach(() => {
193
- meeting = {
194
- processRelayEvent: sinon.stub(),
195
- };
196
-
197
- webex.meetings.getMeetingByType = sinon.stub().returns(meeting);
229
+ relayListener = sinon.stub();
230
+ webinar._practiceSessionRelayListener = relayListener;
198
231
  });
199
232
 
200
- it('disconnects the practice session channel and removes the relay listener', async () => {
233
+ it('disconnects the practice session channel and removes the tracked relay listener', async () => {
201
234
  await webinar.cleanupPSDataChannel();
202
235
 
203
236
  assert.calledOnceWithExactly(
@@ -208,8 +241,28 @@ describe('plugin-meetings', () => {
208
241
  assert.calledOnceWithExactly(
209
242
  webex.internal.llm.off,
210
243
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
211
- meeting.processRelayEvent
244
+ relayListener
212
245
  );
246
+ assert.isNull(webinar._practiceSessionRelayListener);
247
+ });
248
+
249
+ it('skips relay listener removal when no listener has been tracked', async () => {
250
+ webinar._practiceSessionRelayListener = null;
251
+
252
+ await webinar.cleanupPSDataChannel();
253
+
254
+ const relayOffCalls = webex.internal.llm.off.args.filter(
255
+ ([event]) => event === `event:relay.event:${LLM_PRACTICE_SESSION}`
256
+ );
257
+ assert.equal(relayOffCalls.length, 0);
258
+ });
259
+
260
+ it('does not consult the meeting collection during cleanup', async () => {
261
+ webex.meetings.getMeetingByType = sinon.stub();
262
+
263
+ await webinar.cleanupPSDataChannel();
264
+
265
+ assert.notCalled(webex.meetings.getMeetingByType);
213
266
  });
214
267
 
215
268
  it('removes a pending online listener if one exists', async () => {
@@ -240,6 +293,7 @@ describe('plugin-meetings', () => {
240
293
  beforeEach(() => {
241
294
  processRelayEvent = sinon.stub();
242
295
  meeting = {
296
+ locusUrl: 'locusUrl',
243
297
  isJoined: sinon.stub().returns(true),
244
298
  processRelayEvent,
245
299
  locusInfo: {
@@ -418,19 +472,30 @@ describe('plugin-meetings', () => {
418
472
  assert.calledOnce(webex.internal.llm.registerAndConnect);
419
473
  });
420
474
 
421
- it('rebinds relay listener after successful connect', async () => {
475
+ it('tracks and binds the relay listener after successful connect', async () => {
422
476
  await webinar.updatePSDataChannel();
423
477
 
478
+ // Stores the exact listener reference for deterministic cleanup
479
+ assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
424
480
  assert.calledWith(
425
- webex.internal.llm.off,
481
+ webex.internal.llm.on,
426
482
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
427
483
  processRelayEvent
428
484
  );
485
+ });
486
+
487
+ it('removes a previously tracked relay listener before re-binding on reconnect', async () => {
488
+ const previousListener = sinon.stub();
489
+ webinar._practiceSessionRelayListener = previousListener;
490
+
491
+ await webinar.updatePSDataChannel();
492
+
429
493
  assert.calledWith(
430
- webex.internal.llm.on,
494
+ webex.internal.llm.off,
431
495
  `event:relay.event:${LLM_PRACTICE_SESSION}`,
432
- processRelayEvent
496
+ previousListener
433
497
  );
498
+ assert.equal(webinar._practiceSessionRelayListener, processRelayEvent);
434
499
  });
435
500
 
436
501
  it('subscribes to transcription when caption intent is enabled', async () => {
@@ -551,7 +616,7 @@ describe('plugin-meetings', () => {
551
616
  updateMediaShares = sinon.stub()
552
617
  webinar.webex.meetings = {
553
618
  getMeetingByType: sinon.stub().returns({
554
- id: 'meeting-id',
619
+ id: 'meeting-id', locusUrl: 'locusUrl',
555
620
  isJoined: sinon.stub().returns(false),
556
621
  updateLLMConnection: sinon.stub(),
557
622
  shareStatus: SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE,
@@ -606,7 +671,7 @@ describe('plugin-meetings', () => {
606
671
 
607
672
  webinar.webex.meetings = {
608
673
  getMeetingByType: sinon.stub().returns({
609
- id: 'meeting-id',
674
+ id: 'meeting-id', locusUrl: 'locusUrl',
610
675
  isJoined: sinon.stub().returns(false),
611
676
  updateLLMConnection: sinon.stub(),
612
677
  shareStatus: SHARE_STATUS.REMOTE_SHARE_ACTIVE,
@@ -1034,7 +1099,7 @@ describe('plugin-meetings', () => {
1034
1099
  // @ts-ignore
1035
1100
  webinar.webex.meetings = {
1036
1101
  getMeetingByType: sinon.stub().returns({
1037
- id: 'meeting-id',
1102
+ id: 'meeting-id', locusUrl: 'locusUrl',
1038
1103
  locusInfo: {
1039
1104
  links:{
1040
1105
  resources: {
@@ -1051,7 +1116,7 @@ describe('plugin-meetings', () => {
1051
1116
  it('throws an error if attendeeSearchUrl is not available', async () => {
1052
1117
  webinar.webex.meetings = {
1053
1118
  getMeetingByType: sinon.stub().returns({
1054
- id: 'meeting-id',
1119
+ id: 'meeting-id', locusUrl: 'locusUrl',
1055
1120
  locusInfo: {
1056
1121
  links:{
1057
1122
  resources: {