@webex/internal-plugin-voicea 3.12.0-auth-prejoin-fetch.1 → 3.12.0-llmrefactor.10

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.
@@ -4,75 +4,90 @@ import MockWebSocket from '@webex/test-helper-mock-web-socket';
4
4
  import {assert, expect} from '@webex/test-helper-chai';
5
5
  import sinon from 'sinon';
6
6
  import Mercury from '@webex/internal-plugin-mercury';
7
- import LLMChannel from '@webex/internal-plugin-llm';
8
7
 
9
- import VoiceaService from '../../../src/index';
10
- import {
11
- EVENT_TRIGGERS,
12
- LLM_PRACTICE_SESSION,
13
- TOGGLE_MANUAL_CAPTION_STATUS,
14
- } from '../../../src/constants';
8
+ import {VoiceaChannel} from '../../../src/voicea';
9
+ import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
10
+
11
+ const flushPromises = () => new Promise(setImmediate);
12
+
13
+ /**
14
+ * Creates a mock LLM channel for testing
15
+ * @param {Object} [options] - Options for the mock channel
16
+ * @param {boolean} [options.isConnected=true] - Whether the channel is connected
17
+ * @param {string} [options.locusUrl] - The locus URL
18
+ * @returns {Object} Mock channel
19
+ */
20
+ function createMockLLMChannel(options = {}) {
21
+ const mockWebSocket = new MockWebSocket();
22
+ const {isConnected = true, locusUrl = 'locusUrl'} = options;
23
+
24
+ return {
25
+ isConnected: sinon.stub().returns(isConnected),
26
+ getSocket: sinon.stub().returns(mockWebSocket),
27
+ getBinding: sinon.stub().returns('binding'),
28
+ getDatachannelUrl: sinon.stub().returns('datachannelUrl'),
29
+ getLocusUrl: sinon.stub().returns(locusUrl),
30
+ isDataChannelTokenEnabled: sinon.stub().resolves(true),
31
+ on: sinon.stub(),
32
+ off: sinon.stub(),
33
+ once: sinon.stub(),
34
+ socket: mockWebSocket,
35
+ };
36
+ }
15
37
 
16
38
  describe('plugin-voicea', () => {
17
39
  const locusUrl = 'locusUrl';
18
40
 
19
- describe('voicea', () => {
20
- let webex, voiceaService;
41
+ describe('VoiceaChannel', () => {
42
+ let webex, voiceaChannel, mockLLMChannel, requestStub;
21
43
 
22
44
  beforeEach(() => {
23
45
  webex = new MockWebex({
24
46
  children: {
25
47
  mercury: Mercury,
26
- llm: LLMChannel,
27
- voicea: VoiceaService,
28
48
  },
29
49
  });
30
50
 
31
- voiceaService = webex.internal.voicea;
32
- voiceaService.connect = sinon.stub().resolves(true);
33
- voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true);
34
- voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined);
35
- voiceaService.webex.internal.llm.getSocket = sinon.stub().returns(undefined);
36
- voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl);
37
-
38
- voiceaService.request = sinon.stub().resolves({
51
+ requestStub = sinon.stub().resolves({
39
52
  headers: {},
40
53
  body: '',
41
54
  });
42
- voiceaService.register = sinon.stub().resolves({
43
- body: {
44
- binding: 'binding',
45
- webSocketUrl: 'url',
46
- },
47
- });
55
+
56
+ mockLLMChannel = createMockLLMChannel({locusUrl});
57
+ voiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
48
58
  });
49
59
 
50
- describe("#constructor", () => {
60
+ afterEach(() => {
61
+ voiceaChannel.deregisterEvents();
62
+ sinon.restore();
63
+ });
64
+
65
+ describe('#constructor', () => {
51
66
  it('should init status', () => {
52
- assert.equal(voiceaService.announceStatus, 'idle');
53
- assert.equal(voiceaService.captionStatus, 'idle');
67
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
68
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
54
69
  });
55
- });
56
70
 
57
- describe('#sendAnnouncement', () => {
58
- beforeEach(async () => {
59
- const mockWebSocket = new MockWebSocket();
71
+ it('should subscribe to relay events when llmChannel is provided', () => {
72
+ assert.calledWith(mockLLMChannel.on, 'event:relay.event', sinon.match.func);
73
+ });
60
74
 
61
- voiceaService.webex.internal.llm.socket = mockWebSocket;
62
- voiceaService.announceStatus = "idle";
75
+ it('should not subscribe to events when llmChannel is undefined', () => {
76
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
77
+ // No llmChannel means no subscription, just verify it doesn't throw
78
+ assert.equal(channelWithoutLLM.getAnnounceStatus(), 'idle');
63
79
  });
80
+ });
64
81
 
82
+ describe('#sendAnnouncement', () => {
65
83
  it("sends announcement if voicea hasn't joined", () => {
66
- const spy = sinon.spy(voiceaService, 'listenToEvents');
84
+ voiceaChannel.sendAnnouncement();
85
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
67
86
 
68
- voiceaService.sendAnnouncement();
69
- assert.equal(voiceaService.announceStatus, 'joining');
70
- assert.calledOnce(spy);
71
-
72
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
87
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
73
88
  id: '1',
74
89
  type: 'publishRequest',
75
- recipients: [{route: undefined}],
90
+ recipients: [{route: 'binding'}],
76
91
  headers: {},
77
92
  data: {
78
93
  clientPayload: {
@@ -85,31 +100,15 @@ describe('plugin-voicea', () => {
85
100
  });
86
101
  });
87
102
 
88
- it('listens to events once', () => {
89
- const spy = sinon.spy(webex.internal.llm, 'on');
90
-
91
- voiceaService.sendAnnouncement();
92
-
93
- voiceaService.sendAnnouncement();
94
-
95
- assert.calledTwice(spy);
96
- assert.calledWith(spy, 'event:relay.event', sinon.match.func);
97
- assert.calledWith(spy, `event:relay.event:${LLM_PRACTICE_SESSION}`, sinon.match.func);
98
- });
99
-
100
103
  it('includes captionServiceId in headers when set', () => {
101
- const mockWebSocket = new MockWebSocket();
102
-
103
- voiceaService.webex.internal.llm.socket = mockWebSocket;
104
- voiceaService.announceStatus = 'idle';
105
- voiceaService.captionServiceId = 'svc-123';
104
+ voiceaChannel.captionServiceId = 'svc-123';
106
105
 
107
- voiceaService.sendAnnouncement();
106
+ voiceaChannel.sendAnnouncement();
108
107
 
109
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
108
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
110
109
  id: '1',
111
110
  type: 'publishRequest',
112
- recipients: [{route: undefined}],
111
+ recipients: [{route: 'binding'}],
113
112
  headers: {to: 'svc-123'},
114
113
  data: {
115
114
  clientPayload: {
@@ -124,49 +123,38 @@ describe('plugin-voicea', () => {
124
123
  });
125
124
 
126
125
  describe('#sendManualClosedCaption', () => {
127
- beforeEach(async () => {
128
- const mockWebSocket = new MockWebSocket();
129
- voiceaService.webex.internal.llm.socket = mockWebSocket;
130
- voiceaService.seqNum = 1;
131
- });
132
-
133
126
  it('sends interim manual closed caption when connected', () => {
134
127
  const text = 'Test interim caption';
135
128
  const timeStamp = 1234567890;
136
129
  const csis = [123456];
137
130
  const isFinal = false;
138
131
 
139
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
140
-
141
- assert.calledOnceWithExactly(
142
- voiceaService.webex.internal.llm.socket.send,
143
- {
144
- id: '1',
145
- type: 'publishRequest',
146
- recipients: [{route: undefined}],
147
- headers: {},
148
- data: {
149
- eventType: 'relay.event',
150
- relayType: 'client.manual_transcription',
151
- transcriptPayload: {
152
- type: 'manual_caption_interim_result',
153
- id: sinon.match.string,
154
- transcripts: [
155
- {
156
- text: 'Test interim caption',
157
- start_millis: 1234567890,
158
- end_millis: 1234567890,
159
- csis: [123456],
160
- },
161
- ],
162
- transcript_id: sinon.match.string,
163
- },
132
+ voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
133
+
134
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
135
+ id: '1',
136
+ type: 'publishRequest',
137
+ recipients: [{route: 'binding'}],
138
+ headers: {},
139
+ data: {
140
+ eventType: 'relay.event',
141
+ relayType: 'client.manual_transcription',
142
+ transcriptPayload: {
143
+ type: 'manual_caption_interim_result',
144
+ id: sinon.match.string,
145
+ transcripts: [
146
+ {
147
+ text: 'Test interim caption',
148
+ start_millis: 1234567890,
149
+ end_millis: 1234567890,
150
+ csis: [123456],
151
+ },
152
+ ],
153
+ transcript_id: sinon.match.string,
164
154
  },
165
- trackingId: sinon.match.string,
166
- }
167
- );
168
- // seqNum should increment
169
- assert.equal(voiceaService.seqNum, 2);
155
+ },
156
+ trackingId: sinon.match.string,
157
+ });
170
158
  });
171
159
 
172
160
  it('sends final manual closed caption when connected', () => {
@@ -175,137 +163,87 @@ describe('plugin-voicea', () => {
175
163
  const csis = [654321];
176
164
  const isFinal = true;
177
165
 
178
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
179
-
180
- assert.calledOnceWithExactly(
181
- voiceaService.webex.internal.llm.socket.send,
182
- {
183
- id: '1',
184
- type: 'publishRequest',
185
- recipients: [{route: undefined}],
186
- headers: {},
187
- data: {
188
- eventType: 'relay.event',
189
- relayType: 'client.manual_transcription',
190
- transcriptPayload: {
191
- type: 'manual_caption_final_result',
192
- id: sinon.match.string,
193
- transcripts: [
194
- {
195
- text: 'Test final caption',
196
- start_millis: 9876543210,
197
- end_millis: 9876543210,
198
- csis: [654321],
199
- },
200
- ],
201
- transcript_id: sinon.match.string,
202
- },
166
+ voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
167
+
168
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
169
+ id: '1',
170
+ type: 'publishRequest',
171
+ recipients: [{route: 'binding'}],
172
+ headers: {},
173
+ data: {
174
+ eventType: 'relay.event',
175
+ relayType: 'client.manual_transcription',
176
+ transcriptPayload: {
177
+ type: 'manual_caption_final_result',
178
+ id: sinon.match.string,
179
+ transcripts: [
180
+ {
181
+ text: 'Test final caption',
182
+ start_millis: 9876543210,
183
+ end_millis: 9876543210,
184
+ csis: [654321],
185
+ },
186
+ ],
187
+ transcript_id: sinon.match.string,
203
188
  },
204
- trackingId: sinon.match.string,
205
- }
206
- );
207
- // seqNum should increment
208
- assert.equal(voiceaService.seqNum, 2);
189
+ },
190
+ trackingId: sinon.match.string,
191
+ });
209
192
  });
210
193
 
211
194
  it('does not send if not connected', () => {
212
- voiceaService.webex.internal.llm.isConnected.returns(false);
213
-
214
- const text = 'Should not send';
215
- const timeStamp = 111;
216
- const csis = [1];
217
- const isFinal = true;
195
+ const disconnectedChannel = createMockLLMChannel({isConnected: false});
196
+ const channel = new VoiceaChannel(disconnectedChannel, requestStub);
218
197
 
219
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
198
+ channel.sendManualClosedCaption('Should not send', 111, [1], true);
220
199
 
221
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
200
+ assert.notCalled(disconnectedChannel.socket.send);
222
201
  });
223
202
  });
203
+
224
204
  describe('#deregisterEvents', () => {
225
205
  beforeEach(async () => {
226
- const mockWebSocket = new MockWebSocket();
227
- voiceaService.webex.internal.llm.socket = mockWebSocket;
228
- voiceaService.keepTranscriptionSubscribed = true;
206
+ voiceaChannel.keepTranscriptionSubscribed = true;
229
207
  });
230
208
 
231
- it('deregisters voicea service and resets caption state', async () => {
232
- voiceaService.listenToEvents();
233
- await voiceaService.toggleTranscribing(true);
234
-
235
- voiceaService.webex.internal.llm._emit('event:relay.event', {
236
- headers: {from: 'ws'},
237
- data: {relayType: 'voicea.annc', voiceaPayload: {}},
238
- });
209
+ it('works when llmChannel is undefined', () => {
210
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
211
+ channelWithoutLLM.areCaptionsEnabled = true;
212
+ channelWithoutLLM.keepTranscriptionSubscribed = true;
213
+ channelWithoutLLM.hasSubscribedToEvents = true;
239
214
 
240
- assert.equal(voiceaService.areCaptionsEnabled, true);
241
- assert.equal(voiceaService.captionServiceId, 'ws');
242
- assert.equal(voiceaService.keepTranscriptionSubscribed, true);
215
+ // Should not throw
216
+ channelWithoutLLM.deregisterEvents();
243
217
 
244
- voiceaService.deregisterEvents();
245
- assert.equal(voiceaService.areCaptionsEnabled, false);
246
- assert.equal(voiceaService.captionServiceId, undefined);
247
- assert.equal(voiceaService.announceStatus, 'idle');
248
- assert.equal(voiceaService.captionStatus, 'idle');
249
- assert.equal(voiceaService.keepTranscriptionSubscribed, false);
218
+ assert.equal(channelWithoutLLM.areCaptionsEnabled, false);
219
+ assert.equal(channelWithoutLLM.keepTranscriptionSubscribed, false);
220
+ assert.equal(channelWithoutLLM.hasSubscribedToEvents, false);
250
221
  });
251
- });
252
- describe('#processAnnouncementMessage', () => {
253
- it('works on non-empty payload', async () => {
254
- const voiceaPayload = {
255
- translation: {
256
- allowed_languages: ['af', 'am'],
257
- max_languages: 5,
258
- },
259
- ASR: {
260
- spoken_languages: ['en'],
261
- },
262
-
263
- version: 'v2',
264
- };
265
-
266
- const spy = sinon.spy();
267
222
 
268
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
269
- voiceaService.listenToEvents();
270
- voiceaService.processAnnouncementMessage(voiceaPayload);
271
- assert.calledOnceWithExactly(spy, {
272
- captionLanguages: ['af', 'am'],
273
- spokenLanguages: ['en'],
274
- maxLanguages: 5,
275
- currentSpokenLanguage: 'en',
276
- });
277
- });
223
+ it('deregisters voicea channel and resets state', () => {
224
+ voiceaChannel.areCaptionsEnabled = true;
225
+ voiceaChannel.captionServiceId = 'ws';
226
+ voiceaChannel.keepTranscriptionSubscribed = true;
278
227
 
279
- it('works on empty payload', async () => {
280
- const spy = sinon.spy();
228
+ voiceaChannel.deregisterEvents();
281
229
 
282
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
283
- voiceaService.listenToEvents();
284
- voiceaService.currentSpokenLanguage = 'fr';
285
- await voiceaService.processAnnouncementMessage({});
286
- assert.calledOnceWithExactly(spy, {
287
- captionLanguages: [],
288
- spokenLanguages: [],
289
- maxLanguages: 0,
290
- currentSpokenLanguage: 'fr',
291
- });
230
+ assert.equal(voiceaChannel.areCaptionsEnabled, false);
231
+ assert.equal(voiceaChannel.captionServiceId, undefined);
232
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
233
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
234
+ assert.equal(voiceaChannel.keepTranscriptionSubscribed, false);
235
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
292
236
  });
293
237
  });
294
238
 
295
239
  describe('#requestLanguage', () => {
296
- beforeEach(async () => {
297
- const mockWebSocket = new MockWebSocket();
298
-
299
- voiceaService.webex.internal.llm.socket = mockWebSocket;
300
- });
301
-
302
240
  it('requests caption language', () => {
303
- voiceaService.requestLanguage('en');
241
+ voiceaChannel.requestLanguage('en');
304
242
 
305
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
243
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
306
244
  id: '1',
307
245
  type: 'publishRequest',
308
- recipients: [{route: undefined}],
246
+ recipients: [{route: 'binding'}],
309
247
  headers: {to: undefined},
310
248
  data: {
311
249
  clientPayload: {
@@ -320,14 +258,14 @@ describe('plugin-voicea', () => {
320
258
  });
321
259
 
322
260
  it('uses captionServiceId as "to" header when set', () => {
323
- voiceaService.captionServiceId = 'svc-456';
261
+ voiceaChannel.captionServiceId = 'svc-456';
324
262
 
325
- voiceaService.requestLanguage('fr');
263
+ voiceaChannel.requestLanguage('fr');
326
264
 
327
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
265
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
328
266
  id: '1',
329
267
  type: 'publishRequest',
330
- recipients: [{route: undefined}],
268
+ recipients: [{route: 'binding'}],
331
269
  headers: {to: 'svc-456'},
332
270
  data: {
333
271
  clientPayload: {
@@ -340,6 +278,15 @@ describe('plugin-voicea', () => {
340
278
  trackingId: sinon.match.string,
341
279
  });
342
280
  });
281
+
282
+ it('does not send when not connected', () => {
283
+ const disconnectedChannel = createMockLLMChannel({isConnected: false});
284
+ const channel = new VoiceaChannel(disconnectedChannel, requestStub);
285
+
286
+ channel.requestLanguage('en');
287
+
288
+ assert.notCalled(disconnectedChannel.socket.send);
289
+ });
343
290
  });
344
291
 
345
292
  describe('#setSpokenLanguage', () => {
@@ -347,38 +294,33 @@ describe('plugin-voicea', () => {
347
294
  const languageCode = 'en';
348
295
  const triggerSpy = sinon.spy();
349
296
 
350
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
351
- voiceaService.listenToEvents();
352
- await voiceaService.setSpokenLanguage(languageCode);
297
+ voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
298
+ await voiceaChannel.setSpokenLanguage(languageCode);
353
299
 
354
300
  assert.calledOnceWithExactly(triggerSpy, {languageCode});
355
301
 
356
302
  sinon.assert.calledWith(
357
- voiceaService.request,
303
+ requestStub,
358
304
  sinon.match({
359
305
  method: 'PUT',
360
306
  url: `${locusUrl}/controls/`,
361
307
  body: {
362
308
  transcribe: {
363
309
  spokenLanguage: languageCode,
364
- }
310
+ },
365
311
  },
366
312
  })
367
313
  );
368
314
  });
315
+
369
316
  it('sets spoken language with language assignment', async () => {
370
317
  const languageCode = 'zh';
371
318
  const languageAssignment = 'DEFAULT';
372
- const triggerSpy = sinon.spy();
373
-
374
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
375
- voiceaService.listenToEvents();
376
- await voiceaService.setSpokenLanguage(languageCode, languageAssignment);
377
319
 
378
- assert.calledOnceWithExactly(triggerSpy, {languageCode});
320
+ await voiceaChannel.setSpokenLanguage(languageCode, languageAssignment);
379
321
 
380
322
  sinon.assert.calledWith(
381
- voiceaService.request,
323
+ requestStub,
382
324
  sinon.match({
383
325
  method: 'PUT',
384
326
  url: `${locusUrl}/controls/`,
@@ -386,1197 +328,966 @@ describe('plugin-voicea', () => {
386
328
  transcribe: {
387
329
  spokenLanguage: languageCode,
388
330
  languageAssignment,
389
- }
331
+ },
390
332
  },
391
333
  })
392
334
  );
393
335
  });
394
-
395
336
  });
396
337
 
397
- describe('#requestTurnOnCaptions', () => {
398
- beforeEach(async () => {
399
- const mockWebSocket = new MockWebSocket();
400
-
401
- voiceaService.webex.internal.llm.socket = mockWebSocket;
402
- voiceaService.captionStatus = 'idle';
338
+ describe('#isLLMConnected', () => {
339
+ it('returns true when the LLM channel is connected', () => {
340
+ assert.equal(voiceaChannel.isLLMConnected(), true);
403
341
  });
404
342
 
405
- afterEach( () => {
406
- voiceaService.captionStatus = 'idle';
407
- })
408
-
409
- it('turns on captions', async () => {
410
- const announcementSpy = sinon.spy(voiceaService, 'announce');
411
- const updateSubchannelSubscriptionsAndSyncCaptionStateSpy = sinon.spy(voiceaService, 'updateSubchannelSubscriptionsAndSyncCaptionState');
412
-
413
- const triggerSpy = sinon.spy();
414
-
415
- voiceaService.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
416
- voiceaService.listenToEvents();
417
-
418
- await voiceaService.requestTurnOnCaptions();
419
- assert.equal(voiceaService.captionStatus, 'enabled');
420
- sinon.assert.calledWith(
421
- voiceaService.request,
422
- sinon.match({
423
- method: 'PUT',
424
- url: `${locusUrl}/controls/`,
425
- body: {transcribe: {caption: true}},
426
- })
427
- );
428
-
429
- assert.calledOnceWithExactly(triggerSpy);
430
-
431
- assert.calledOnce(announcementSpy);
432
- assert.calledOnceWithExactly(
433
- updateSubchannelSubscriptionsAndSyncCaptionStateSpy,
434
- { subscribe: ['transcription'] },
435
- true
436
- );
343
+ it('returns false when the LLM channel is not connected', () => {
344
+ mockLLMChannel.isConnected.returns(false);
345
+ assert.equal(voiceaChannel.isLLMConnected(), false);
437
346
  });
438
347
 
439
- it("should handle request fail", async () => {
440
- voiceaService.captionStatus = 'sending';
441
- voiceaService.request = sinon.stub().rejects();
442
-
443
- try {
444
- await voiceaService.requestTurnOnCaptions();
445
- } catch (error) {
446
- expect(error.message).to.include('turn on captions fail');
447
- return;
448
- }
449
- assert.equal(voiceaService.captionStatus, 'idle');
348
+ it('returns false when the LLM channel is undefined', () => {
349
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
350
+ assert.equal(channelWithoutLLM.isLLMConnected(), false);
450
351
  });
451
352
  });
452
353
 
453
- describe("#isAnnounceProcessing", () => {
454
- afterEach(() => {
455
- voiceaService.announceStatus = 'idle';
456
- });
457
-
458
- ['joining', 'joined'].forEach((status) => {
459
- it(`should return true when status is ${status}`, () => {
460
- voiceaService.announceStatus = status;
461
- assert.equal(voiceaService.isAnnounceProcessing(), true);
462
- });
354
+ describe('#getKeepTranscriptionSubscribed', () => {
355
+ it('returns false when keepTranscriptionSubscribed is false', () => {
356
+ voiceaChannel.keepTranscriptionSubscribed = false;
357
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
463
358
  });
464
359
 
465
- it('should return false when status is not processing status', () => {
466
- voiceaService.announceStatus = 'idle';
467
- assert.equal(voiceaService.isAnnounceProcessing(), false);
360
+ it('returns true when keepTranscriptionSubscribed is true', () => {
361
+ voiceaChannel.keepTranscriptionSubscribed = true;
362
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
468
363
  });
469
364
  });
470
365
 
471
- describe('#isLLMConnected', () => {
472
- it('returns true when the default llm connection is connected', () => {
473
- voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
474
- channel === LLM_PRACTICE_SESSION ? false : true
475
- );
476
-
477
- assert.equal(voiceaService.isLLMConnected(), true);
366
+ describe('#announce', () => {
367
+ it('announce to llm data channel', () => {
368
+ const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
369
+ voiceaChannel.announce();
370
+ assert.calledOnce(sendAnnouncementSpy);
478
371
  });
479
372
 
480
- it('returns true when only the practice session llm connection is connected', () => {
481
- voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
482
- channel === LLM_PRACTICE_SESSION
373
+ it('throws when llm is not connected', () => {
374
+ mockLLMChannel.isConnected.returns(false);
375
+ assert.throws(
376
+ () => voiceaChannel.announce(),
377
+ 'voicea can not announce before llm connected'
483
378
  );
484
-
485
- assert.equal(voiceaService.isLLMConnected(), true);
486
379
  });
487
380
 
488
- it('returns false when neither llm connection is connected', () => {
489
- voiceaService.webex.internal.llm.isConnected.returns(false);
490
-
491
- assert.equal(voiceaService.isLLMConnected(), false);
381
+ it('should not announce duplicate when already processed', () => {
382
+ voiceaChannel.announceStatus = 'joined';
383
+ const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
384
+ voiceaChannel.announce();
385
+ assert.notCalled(sendAnnouncementSpy);
492
386
  });
493
387
  });
494
388
 
495
- describe('#getKeepTranscriptionSubscribed', () => {
496
- beforeEach(() => {
497
- voiceaService.keepTranscriptionSubscribed = false;
498
- });
499
-
500
- it('returns false when captions are disabled', () => {
501
- voiceaService.keepTranscriptionSubscribed = false;
502
-
503
- const result = voiceaService.getKeepTranscriptionSubscribed();
504
-
505
- assert.equal(result, false);
506
- });
507
-
508
- it('returns true when captions are enabled', () => {
509
- voiceaService.keepTranscriptionSubscribed = true;
510
-
511
- const result = voiceaService.getKeepTranscriptionSubscribed();
512
-
513
- assert.equal(result, true);
514
- });
515
- });
389
+ describe('#turnOnCaptions', () => {
390
+ it('turns on captions', async () => {
391
+ const announceSpy = sinon.spy(voiceaChannel, 'announce');
392
+ const triggerSpy = sinon.spy();
516
393
 
517
- describe("#announce", () => {
518
- let isAnnounceProcessed, sendAnnouncement;
519
- beforeEach(() => {
520
- sendAnnouncement = sinon.stub(voiceaService, 'sendAnnouncement');
521
- isAnnounceProcessed = sinon.stub(voiceaService, 'isAnnounceProcessed').returns(false)
522
- });
394
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
523
395
 
524
- afterEach(() => {
525
- isAnnounceProcessed.restore();
526
- sendAnnouncement.restore();
527
- });
396
+ await voiceaChannel.turnOnCaptions();
528
397
 
529
- it('announce to llm data channel', ()=> {
530
- voiceaService.announce();
531
- assert.calledOnce(sendAnnouncement);
398
+ assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
399
+ assert.calledOnce(announceSpy);
400
+ assert.calledOnce(triggerSpy);
532
401
  });
533
402
 
534
- it('announce to llm data channel before llm connected', ()=> {
535
- voiceaService.webex.internal.llm.isConnected.returns(false);
536
- assert.throws(() => voiceaService.announce(), "voicea can not announce before llm connected");
537
- assert.notCalled(sendAnnouncement);
538
- });
403
+ it('throws when llm is not connected', async () => {
404
+ mockLLMChannel.isConnected.returns(false);
539
405
 
540
- it('announce to llm data channel when only practice session is connected', ()=> {
541
- voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
542
- channel === LLM_PRACTICE_SESSION
406
+ await assert.isRejected(
407
+ voiceaChannel.turnOnCaptions(),
408
+ 'can not turn on captions before llm connected'
543
409
  );
544
-
545
- voiceaService.announce();
546
-
547
- assert.calledOnce(sendAnnouncement);
548
- });
549
-
550
- it('should not announce duplicate', () => {
551
- isAnnounceProcessed.returns(true);
552
- voiceaService.announce();
553
- assert.notCalled(sendAnnouncement);
554
- })
555
- });
556
-
557
- describe("#isCaptionProcessing", () => {
558
- afterEach(() => {
559
- voiceaService.captionStatus = 'idle';
560
- });
561
-
562
- ['sending', 'enabled'].forEach((status) => {
563
- it(`should return true when status is ${status}`, () => {
564
- voiceaService.captionStatus = status;
565
- assert.equal(voiceaService.isCaptionProcessing(), true);
566
- });
567
410
  });
568
411
 
569
- it('should return false when status is not processing status', () => {
570
- voiceaService.captionStatus = 'idle';
571
- assert.equal(voiceaService.isCaptionProcessing(), false);
412
+ it('returns undefined when already sending', async () => {
413
+ voiceaChannel.captionStatus = 'sending';
414
+ const result = await voiceaChannel.turnOnCaptions();
415
+ assert.equal(result, undefined);
572
416
  });
573
- });
574
417
 
575
- describe('#turnOnCaptions', () => {
576
- let requestTurnOnCaptions;
577
- beforeEach(() => {
578
- requestTurnOnCaptions = sinon.stub(voiceaService, 'requestTurnOnCaptions');
579
- voiceaService.captionStatus = 'idle';
580
- });
418
+ it('returns undefined when already enabled', async () => {
419
+ voiceaChannel.captionStatus = 'enabled';
581
420
 
582
- afterEach(() => {
583
- requestTurnOnCaptions.restore();
584
- voiceaService.captionStatus = 'idle';
585
- });
421
+ const result = await voiceaChannel.turnOnCaptions();
586
422
 
587
- it('call request turn on captions', () => {
588
- voiceaService.captionStatus = 'idle';
589
- voiceaService.turnOnCaptions();
590
- assert.calledOnce(requestTurnOnCaptions);
423
+ assert.equal(result, undefined);
424
+ assert.notCalled(requestStub);
591
425
  });
592
426
 
593
- it('throws before turning on captions when llm is not connected', async () => {
594
- voiceaService.captionStatus = 'idle';
595
- voiceaService.webex.internal.llm.isConnected.returns(false);
427
+ it('throws error on request failure', async () => {
428
+ const requestError = new Error('Request failed');
429
+ requestStub.rejects(requestError);
596
430
 
597
- await assert.isRejected(
598
- voiceaService.turnOnCaptions(),
599
- 'can not turn on captions before llm connected'
431
+ const error = await assert.isRejected(
432
+ voiceaChannel.turnOnCaptions(),
433
+ 'turn on captions fail'
600
434
  );
601
- assert.notCalled(requestTurnOnCaptions);
602
- });
603
435
 
604
- it('turns on captions when only the practice session llm connection is connected', () => {
605
- voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
606
- channel === LLM_PRACTICE_SESSION
607
- );
436
+ assert.equal(error.cause, requestError);
437
+ });
608
438
 
609
- voiceaService.turnOnCaptions();
439
+ it('resets caption status to idle on error', async () => {
440
+ requestStub.rejects(new Error('Request failed'));
610
441
 
611
- assert.calledOnce(requestTurnOnCaptions);
612
- });
442
+ try {
443
+ await voiceaChannel.turnOnCaptions();
444
+ } catch {
445
+ // expected
446
+ }
613
447
 
614
- it('should not turn on duplicate when processing', () => {
615
- voiceaService.captionStatus = 'sending';
616
- voiceaService.turnOnCaptions();
617
- assert.notCalled(voiceaService.requestTurnOnCaptions);
448
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
618
449
  });
619
450
  });
620
451
 
621
452
  describe('#toggleTranscribing', () => {
622
- beforeEach(async () => {
623
- const mockWebSocket = new MockWebSocket();
624
-
625
- voiceaService.webex.internal.llm.socket = mockWebSocket;
626
- });
627
-
628
- it('turns on transcribing with CC enabled', async () => {
629
- // Turn on captions
630
- await voiceaService.turnOnCaptions();
631
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
632
-
633
- // eslint-disable-next-line no-underscore-dangle
634
- voiceaService.webex.internal.llm._emit('event:relay.event', {
635
- headers: {from: 'ws'},
636
- data: {relayType: 'voicea.annc', voiceaPayload: {}},
637
- });
453
+ it('turns on transcribing', async () => {
454
+ await voiceaChannel.toggleTranscribing(true);
638
455
 
639
- voiceaService.listenToEvents();
640
-
641
- await voiceaService.toggleTranscribing(true);
642
456
  sinon.assert.calledWith(
643
- voiceaService.request,
457
+ requestStub,
644
458
  sinon.match({
645
459
  method: 'PUT',
646
460
  url: `${locusUrl}/controls/`,
647
461
  body: {transcribe: {transcribing: true}},
648
462
  })
649
463
  );
650
-
651
- assert.notCalled(announcementSpy);
652
464
  });
653
465
 
654
- it('turns on transcribing with CC disabled', async () => {
655
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
656
-
657
- voiceaService.listenToEvents();
466
+ it('turns off transcribing', async () => {
467
+ await voiceaChannel.toggleTranscribing(false);
658
468
 
659
- await voiceaService.toggleTranscribing(true);
660
469
  sinon.assert.calledWith(
661
- voiceaService.request,
470
+ requestStub,
662
471
  sinon.match({
663
472
  method: 'PUT',
664
473
  url: `${locusUrl}/controls/`,
665
- body: {transcribe: {transcribing: true}},
474
+ body: {transcribe: {transcribing: false}},
666
475
  })
667
476
  );
668
-
669
- assert.calledOnce(announcementSpy);
670
477
  });
671
478
 
672
- it('turns off transcribing', async () => {
673
- await voiceaService.toggleTranscribing(true);
479
+ it('calls turnOnCaptions when activating and captions not enabled', async () => {
480
+ voiceaChannel.areCaptionsEnabled = false;
481
+ const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
482
+
483
+ await voiceaChannel.toggleTranscribing(true, 'en');
674
484
 
675
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
485
+ assert.calledOnceWithExactly(turnOnCaptionsSpy, 'en');
486
+ });
676
487
 
677
- voiceaService.listenToEvents();
488
+ it('does not call turnOnCaptions when captions already enabled', async () => {
489
+ voiceaChannel.areCaptionsEnabled = true;
490
+ const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
678
491
 
679
- await voiceaService.toggleTranscribing(false);
680
- sinon.assert.calledWith(
681
- voiceaService.request,
682
- sinon.match({
683
- method: 'PUT',
684
- url: `${locusUrl}/controls/`,
685
- body: {transcribe: {transcribing: true}},
686
- })
687
- );
492
+ await voiceaChannel.toggleTranscribing(true);
688
493
 
689
- assert.notCalled(announcementSpy);
494
+ assert.notCalled(turnOnCaptionsSpy);
690
495
  });
691
496
  });
692
497
 
693
498
  describe('#toggleManualCaption', () => {
694
- beforeEach(async () => {
695
- const mockWebSocket = new MockWebSocket();
696
-
697
- voiceaService.webex.internal.llm.socket = mockWebSocket;
698
- voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
699
- });
700
-
701
499
  it('turns on manual caption', async () => {
702
- await voiceaService.toggleManualCaption(true);
500
+ await voiceaChannel.toggleManualCaption(true);
501
+
703
502
  sinon.assert.calledWith(
704
- voiceaService.request,
503
+ requestStub,
705
504
  sinon.match({
706
505
  method: 'PUT',
707
506
  url: `${locusUrl}/controls/`,
708
507
  body: {manualCaption: {enable: true}},
709
508
  })
710
509
  );
711
-
712
510
  });
713
511
 
714
-
715
512
  it('turns off manual caption', async () => {
716
- await voiceaService.toggleManualCaption(false);
513
+ await voiceaChannel.toggleManualCaption(false);
514
+
717
515
  sinon.assert.calledWith(
718
- voiceaService.request,
516
+ requestStub,
719
517
  sinon.match({
720
518
  method: 'PUT',
721
519
  url: `${locusUrl}/controls/`,
722
520
  body: {manualCaption: {enable: false}},
723
521
  })
724
522
  );
523
+ });
524
+
525
+ it('ignores when already sending', async () => {
526
+ voiceaChannel.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
527
+ await voiceaChannel.toggleManualCaption(true);
528
+ sinon.assert.notCalled(requestStub);
529
+ });
530
+
531
+ it('throws error on request failure', async () => {
532
+ requestStub.rejects(new Error('Request failed'));
725
533
 
534
+ await assert.isRejected(
535
+ voiceaChannel.toggleManualCaption(true),
536
+ 'toggle manual captions fail'
537
+ );
726
538
  });
727
539
 
728
- it('ignore toggle manual caption', async () => {
729
- voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
730
- await voiceaService.toggleManualCaption(true);
540
+ it('resets status to idle on error', async () => {
541
+ requestStub.rejects(new Error('Request failed'));
542
+
543
+ try {
544
+ await voiceaChannel.toggleManualCaption(true);
545
+ } catch {
546
+ // expected
547
+ }
548
+
549
+ assert.equal(voiceaChannel.toggleManualCaptionStatus, TOGGLE_MANUAL_CAPTION_STATUS.IDLE);
550
+ });
551
+ });
731
552
 
732
- sinon.assert.notCalled(voiceaService.request);
553
+ describe('#getCaptionStatus', () => {
554
+ it('returns current caption status', () => {
555
+ voiceaChannel.captionStatus = 'enabled';
556
+ assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
557
+ });
558
+ });
733
559
 
560
+ describe('#getAnnounceStatus', () => {
561
+ it('returns current announce status', () => {
562
+ voiceaChannel.announceStatus = 'joined';
563
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
734
564
  });
735
565
  });
736
566
 
737
- describe('#processCaptionLanguageResponse', () => {
738
- it('responds to process caption language', async () => {
567
+ describe('#onSpokenLanguageUpdate', () => {
568
+ it('should trigger SPOKEN_LANGUAGE_UPDATE event', () => {
739
569
  const triggerSpy = sinon.spy();
740
- const functionSpy = sinon.spy(voiceaService, 'processCaptionLanguageResponse');
570
+ voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
741
571
 
742
- voiceaService.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, triggerSpy);
743
- voiceaService.listenToEvents();
572
+ voiceaChannel.onSpokenLanguageUpdate('fr', '123');
744
573
 
745
- // eslint-disable-next-line no-underscore-dangle
746
- voiceaService.webex.internal.llm._emit('event:relay.event', {
747
- headers: {from: 'ws'},
748
- data: {
749
- relayType: 'voicea.transl.rsp',
750
- voiceaPayload: {
751
- statusCode: 200,
752
- },
753
- },
754
- });
574
+ assert.equal(voiceaChannel.currentSpokenLanguage, 'fr');
575
+ assert.calledOnceWithExactly(triggerSpy, {languageCode: 'fr', meetingId: '123'});
576
+ });
577
+ });
755
578
 
756
- assert.calledOnceWithExactly(triggerSpy, {statusCode: 200});
757
- assert.calledOnce(functionSpy);
579
+ describe('#onCaptionServiceIdUpdate', () => {
580
+ it('does nothing when serviceId is falsy', () => {
581
+ voiceaChannel.captionServiceId = 'existing-id';
582
+ voiceaChannel.onCaptionServiceIdUpdate(undefined);
583
+ voiceaChannel.onCaptionServiceIdUpdate('');
584
+ assert.equal(voiceaChannel.captionServiceId, 'existing-id');
758
585
  });
759
586
 
760
- it('responds to process caption language for a failed response', async () => {
761
- const triggerSpy = sinon.spy();
762
- const functionSpy = sinon.spy(voiceaService, 'processCaptionLanguageResponse');
587
+ it('sets captionServiceId when no currentCaptionLanguage', () => {
588
+ voiceaChannel.captionServiceId = undefined;
589
+ voiceaChannel.currentCaptionLanguage = undefined;
590
+ voiceaChannel.onCaptionServiceIdUpdate('svc-new');
591
+ assert.equal(voiceaChannel.captionServiceId, 'svc-new');
592
+ });
763
593
 
764
- voiceaService.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, triggerSpy);
765
- voiceaService.listenToEvents();
594
+ it('re-sends language when serviceId changes and currentCaptionLanguage is set', () => {
595
+ voiceaChannel.captionServiceId = 'old-svc';
596
+ voiceaChannel.currentCaptionLanguage = 'es';
766
597
 
767
- const payload = {
768
- errorCode: 300,
769
- message: 'error text',
770
- };
598
+ voiceaChannel.onCaptionServiceIdUpdate('new-svc');
771
599
 
772
- // eslint-disable-next-line no-underscore-dangle
773
- voiceaService.webex.internal.llm._emit('event:relay.event', {
774
- headers: {from: 'ws'},
775
- data: {relayType: 'voicea.transl.rsp', voiceaPayload: payload},
776
- });
777
- assert.calledOnce(functionSpy);
778
- assert.calledOnceWithExactly(triggerSpy, {statusCode: 300, errorMessage: 'error text'});
600
+ assert.equal(voiceaChannel.captionServiceId, 'new-svc');
601
+ assert.calledOnce(mockLLMChannel.socket.send);
779
602
  });
780
603
  });
781
604
 
782
- describe('#processTranscription', () => {
783
- let triggerSpy, functionSpy;
605
+ describe('#updateSubchannelSubscriptions', () => {
606
+ it('sends subchannelSubscriptionRequest', async () => {
607
+ await voiceaChannel.updateSubchannelSubscriptions({
608
+ subscribe: ['transcription'],
609
+ unsubscribe: ['polls'],
610
+ });
784
611
 
785
- beforeEach(() => {
786
- triggerSpy = sinon.spy();
787
- functionSpy = sinon.spy(voiceaService, 'processTranscription');
788
- voiceaService.listenToEvents();
789
- });
790
-
791
- it('processes interim transcription', async () => {
792
- voiceaService.on(EVENT_TRIGGERS.NEW_CAPTION, triggerSpy);
793
- const transcripts = [
794
- {
795
- text: 'Hello.',
796
- csis: [3556942592],
797
- transcript_language_code: 'en',
798
- translations: {
799
- fr: 'Bonjour.',
800
- },
801
- },
802
- {
803
- text: 'This is Webex',
804
- csis: [3556942593],
805
- transcript_language_code: 'en',
806
- translations: {
807
- fr: "C'est Webex",
808
- },
612
+ sinon.assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
613
+ id: '1',
614
+ type: 'subchannelSubscriptionRequest',
615
+ data: {
616
+ datachannelUri: 'datachannelUrl',
617
+ subscribe: ['transcription'],
618
+ unsubscribe: ['polls'],
809
619
  },
810
- ];
811
- const voiceaPayload = {
812
- audio_received_millis: 0,
813
- command_response: '',
814
- csis: [3556942592],
815
- data: 'Hello.',
816
- id: '38093ff5-f6a8-581c-9e59-035ec027994b',
817
- meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
818
- transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
819
- ts: 1611653204.3147924,
820
- type: 'transcript_interim_results',
821
-
822
- transcripts,
823
- };
824
-
825
- // eslint-disable-next-line no-underscore-dangle
826
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
827
- headers: {from: 'ws'},
828
- data: {relayType: 'voicea.transcription', voiceaPayload},
620
+ trackingId: sinon.match.string,
829
621
  });
622
+ });
830
623
 
831
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
832
- assert.calledOnceWithExactly(triggerSpy, {
833
- isFinal: false,
834
- transcriptId: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
835
- transcripts,
836
- });
624
+ it('does nothing when LLM is not connected', async () => {
625
+ mockLLMChannel.isConnected.returns(false);
626
+
627
+ await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
628
+
629
+ sinon.assert.notCalled(mockLLMChannel.socket.send);
837
630
  });
838
631
 
839
- it('processes final transcription', async () => {
840
- voiceaService.on(EVENT_TRIGGERS.NEW_CAPTION, triggerSpy);
632
+ it('does nothing when dataChannelToken is not enabled', async () => {
633
+ mockLLMChannel.isDataChannelTokenEnabled.resolves(false);
841
634
 
842
- const voiceaPayload = {
843
- audio_received_millis: 0,
844
- command_response: '',
845
- csis: [3556942592],
846
- data: 'Hello. This is Webex',
847
- id: '38093ff5-f6a8-581c-9e59-035ec027994b',
848
- meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
849
- transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
850
- ts: 1611653204.3147924,
851
- type: 'transcript_final_result',
852
- translations: {
853
- en: "Hello?",
854
- },
855
- transcript: {
856
- alignments: [
857
- {
858
- end_millis: 12474,
859
- start_millis: 12204,
860
- word: 'Hello?',
861
- },
862
- ],
863
- csis: [3556942592],
864
- end_millis: 13044,
865
- last_packet_timestamp_ms: 1611653206784,
866
- start_millis: 12204,
867
- text: 'Hello?',
868
- transcript_language_code: 'en',
869
- timestamp: '0:13'
870
- },
871
- transcripts: [
872
- {
873
- start_millis: 12204,
874
- end_millis: 13044,
875
- text: 'Hello.',
876
- csis: [3556942592],
877
- transcript_language_code: 'en',
878
- translations: {
879
- fr: 'Bonjour.',
880
- },
881
- timestamp: '0:13'
882
- },
883
- {
884
- start_millis: 12204,
885
- end_millis: 13044,
886
- text: 'This is Webex',
887
- csis: [3556942593],
888
- transcript_language_code: 'en',
889
- translations: {
890
- fr: "C'est Webex",
891
- },
892
- timestamp: '0:13'
893
- },
894
- ],
895
- };
635
+ await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
896
636
 
897
- // eslint-disable-next-line no-underscore-dangle
898
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
899
- headers: {from: 'ws'},
900
- data: {relayType: 'voicea.transcription', voiceaPayload},
901
- });
902
-
903
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
904
- assert.calledOnceWithExactly(triggerSpy, {
905
- isFinal: true,
906
- transcriptId: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
907
- transcripts: voiceaPayload.transcripts,
908
- });
637
+ sinon.assert.notCalled(mockLLMChannel.socket.send);
909
638
  });
639
+ });
910
640
 
911
- it('processes a eva wake up', async () => {
912
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
913
-
914
- const voiceaPayload = {
915
- audio_received_millis: 1616137504810,
916
- command_response: '',
917
- id: '31fb2f81-fb55-4257-32a0-f421ef8ba4b0',
918
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
919
- trigger: {
920
- detected_at: '2021-03-19T07:05:04.810669662Z',
921
- ews_confidence: 0.99497044086456299,
922
- ews_keyphrase: 'OkayWebEx',
923
- model_version: 'WebEx',
924
- offset_seconds: 2336.5900000000001,
925
- recording_file_name:
926
- 'OkayWebEx_fd5bd0fc-06fb-4fd1-982b-554c4368f101_47900f3f-8579-25eb-3f6a-74d81a3c66a4_2335.8900000000003_2336.79.raw',
927
- type: 'live-hotword',
928
- },
929
- ts: 1616137504.8107769,
930
- type: 'eva_wake',
931
- };
641
+ describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
642
+ it('updates caption intent and forwards to updateSubchannelSubscriptions', async () => {
643
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
932
644
 
933
- // eslint-disable-next-line no-underscore-dangle
934
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
935
- headers: {from: 'ws'},
936
- data: {relayType: 'voicea.transcription', voiceaPayload},
937
- });
645
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
646
+ {subscribe: ['transcription']},
647
+ true
648
+ );
938
649
 
939
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
940
- assert.calledOnceWithExactly(triggerSpy, {
941
- isListening: true,
942
- });
650
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
651
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
943
652
  });
944
653
 
945
- it('processes a eva thanks', async () => {
946
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
947
-
948
- const voiceaPayload = {
949
- audio_received_millis: 0,
950
- command_response: 'OK! Decision created.',
951
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
952
- intent: 'decision',
953
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
954
- ts: 1616135828.2552843,
955
- type: 'eva_thanks',
956
- };
654
+ it('sets caption intent to false when isCCBoxOpen is false', async () => {
655
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
957
656
 
958
- // eslint-disable-next-line no-underscore-dangle
959
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
960
- headers: {from: 'ws'},
961
- data: {relayType: 'voicea.transcription', voiceaPayload},
962
- });
657
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
658
+ {subscribe: ['transcription']},
659
+ false
660
+ );
963
661
 
964
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
965
- assert.calledOnceWithExactly(triggerSpy, {
966
- isListening: false,
967
- text: 'OK! Decision created.',
968
- });
662
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
663
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
969
664
  });
970
665
 
971
- it('processes a eva cancel', async () => {
972
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
973
-
974
- const voiceaPayload = {
975
- audio_received_millis: 0,
976
- command_response: '',
977
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
978
- intent: 'decision',
979
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
980
- ts: 1616135828.2552843,
981
- type: 'eva_cancel',
982
- };
983
-
984
- // eslint-disable-next-line no-underscore-dangle
985
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
986
- headers: {from: 'ws'},
987
- data: {relayType: 'voicea.transcription', voiceaPayload},
988
- });
666
+ it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
667
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
989
668
 
990
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
669
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
991
670
 
992
- assert.calledOnceWithExactly(triggerSpy, {
993
- isListening: false,
994
- });
671
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
672
+ assert.calledOnceWithExactly(updateSpy, {});
995
673
  });
996
674
 
997
- it('processes a highlight', async () => {
998
- voiceaService.on(EVENT_TRIGGERS.HIGHLIGHT_CREATED, triggerSpy);
999
- const voiceaPayload = {
1000
- audio_received_millis: 0,
1001
- command_response: '',
1002
- highlight: {
1003
- created_by_email: '',
1004
- csis: [3932881920],
1005
- end_millis: 660160,
1006
- highlight_id: '219af4b1-1579-5106-53ab-f621094a0c5a',
1007
- highlight_label: 'Decision',
1008
- highlight_source: 'voice-command',
1009
- start_millis: 652756,
1010
- transcript: 'Create a decision to move ahead with the last proposal.',
1011
- trigger_info: {type: 'live-hotword'},
1012
- },
1013
- id: 'e6df0262-6289-db2e-581a-d44bb41b1c9c',
1014
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
1015
- ts: 1616135858.5349569,
1016
- type: 'highlight_created',
1017
- };
675
+ it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
676
+ mockLLMChannel.isConnected.returns(false);
677
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
1018
678
 
1019
- // eslint-disable-next-line no-underscore-dangle
1020
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1021
- headers: {from: 'ws'},
1022
- data: {relayType: 'voicea.transcription', voiceaPayload},
1023
- });
679
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
680
+ {subscribe: ['transcription']},
681
+ true
682
+ );
1024
683
 
1025
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
1026
- assert.calledOnceWithExactly(triggerSpy, {
1027
- csis: [3932881920],
1028
- highlightId: '219af4b1-1579-5106-53ab-f621094a0c5a',
1029
- text: 'Create a decision to move ahead with the last proposal.',
1030
- highlightLabel: 'Decision',
1031
- highlightSource: 'voice-command',
1032
- timestamp: '11:00',
1033
- });
684
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
685
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
1034
686
  });
687
+ });
1035
688
 
1036
- it('processes a language detected if language is in spoken languages', async () => {
1037
- voiceaService.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, triggerSpy);
1038
-
1039
- const voiceaPayload = {
1040
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
1041
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
1042
- type: 'language_detected',
1043
- language: 'en',
1044
- translation: {
1045
- allowed_languages: ['af', 'am'],
1046
- max_languages: 5,
1047
- },
1048
- ASR: {
1049
- spoken_languages: ['en', 'pl'],
1050
- },
1051
-
1052
- version: 'v2',
1053
- };
689
+ describe('event processor', () => {
690
+ let eventHandler;
1054
691
 
1055
- const spy = sinon.spy();
692
+ beforeEach(() => {
693
+ // Get the registered event handler
694
+ const onCall = mockLLMChannel.on
695
+ .getCalls()
696
+ .find((call) => call.args[0] === 'event:relay.event');
697
+ eventHandler = onCall?.args[1];
698
+ });
1056
699
 
1057
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
1058
- voiceaService.listenToEvents();
1059
- voiceaService.processAnnouncementMessage(voiceaPayload);
700
+ it('processes voicea announcement events', () => {
701
+ const spy = sinon.spy();
702
+ voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
1060
703
 
1061
- // eslint-disable-next-line no-underscore-dangle
1062
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1063
- headers: {from: 'ws'},
1064
- data: {relayType: 'voicea.transcription', voiceaPayload},
704
+ eventHandler({
705
+ sequenceNumber: 1,
706
+ headers: {from: 'ws-service'},
707
+ data: {
708
+ relayType: 'voicea.annc',
709
+ voiceaPayload: {
710
+ translation: {allowed_languages: ['en', 'es'], max_languages: 3},
711
+ ASR: {spoken_languages: ['en']},
712
+ },
713
+ },
1065
714
  });
1066
715
 
1067
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
1068
- assert.calledOnceWithExactly(triggerSpy, {
1069
- languageCode: 'en',
716
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
717
+ assert.calledOnceWithExactly(spy, {
718
+ captionLanguages: ['en', 'es'],
719
+ maxLanguages: 3,
720
+ spokenLanguages: ['en'],
721
+ currentSpokenLanguage: 'en',
1070
722
  });
1071
723
  });
1072
724
 
1073
- });
1074
-
1075
- describe('#processManualTranscription', () => {
1076
- let triggerSpy, functionSpy;
1077
-
1078
- beforeEach(() => {
1079
- triggerSpy = sinon.spy();
1080
- functionSpy = sinon.spy(voiceaService, 'processManualTranscription');
1081
- voiceaService.listenToEvents();
1082
- });
1083
-
1084
- it('processes interim manual transcription from aibridge', async () => {
1085
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1086
-
1087
- const transcriptPayload = {
1088
- id: "747d711d-3414-fd69-7081-e842649f2d28",
1089
- transcripts: [
1090
- {
1091
- text: "Good",
1092
- }
1093
- ],
1094
- type: "manual_caption_interim_result",
1095
- };
725
+ it('processes voicea announcement with empty payload', () => {
726
+ const spy = sinon.spy();
727
+ voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
1096
728
 
1097
- // eslint-disable-next-line no-underscore-dangle
1098
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1099
- headers: {from: 'ws'},
1100
- data: {relayType: 'aibridge.manual_transcription', transcriptPayload},
729
+ eventHandler({
730
+ sequenceNumber: 1,
731
+ headers: {from: 'ws-service'},
732
+ data: {
733
+ relayType: 'voicea.annc',
734
+ voiceaPayload: {},
735
+ },
1101
736
  });
1102
737
 
1103
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: 'ws', data_source: 'aibridge.manual_transcription'});
1104
- assert.calledOnceWithExactly(triggerSpy, {
1105
- isFinal: false,
1106
- transcriptId: '747d711d-3414-fd69-7081-e842649f2d28',
1107
- transcripts: transcriptPayload.transcripts,
1108
- sender: 'ws',
1109
- source: 'aibridge.manual_transcription'
738
+ assert.calledOnceWithExactly(spy, {
739
+ captionLanguages: [],
740
+ maxLanguages: 0,
741
+ spokenLanguages: [],
742
+ currentSpokenLanguage: 'en',
1110
743
  });
1111
744
  });
1112
745
 
1113
- it('processes final manual transcription from aibridge', async () => {
1114
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1115
-
1116
- const transcriptPayload = {
1117
- id: "8d226d31-044a-8d11-cc39-cedbde183154",
1118
- transcripts: [
746
+ it('processes speaker name updates and advances the message sequence', () => {
747
+ const spy = sinon.spy();
748
+ const voiceaPayload = {
749
+ id: 'speaker-name-update-1',
750
+ taggedSpeakers: [
1119
751
  {
1120
- text: "Good Morning",
1121
- start_millis: 10420,
1122
- end_millis: 11380,
1123
- }
752
+ csiId: 3556942592,
753
+ speakerId: '1',
754
+ newName: 'Alice',
755
+ },
1124
756
  ],
1125
- type: "manual_caption_final_result",
1126
757
  };
1127
758
 
1128
- // eslint-disable-next-line no-underscore-dangle
1129
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
759
+ voiceaChannel.on(EVENT_TRIGGERS.SPEAKER_NAME_UPDATED, spy);
760
+
761
+ eventHandler({
762
+ sequenceNumber: 23,
1130
763
  headers: {from: 'ws'},
1131
- data: {relayType: 'aibridge.manual_transcription', transcriptPayload},
764
+ data: {
765
+ relayType: 'voicea.update_speakername',
766
+ voiceaPayload,
767
+ },
1132
768
  });
1133
769
 
1134
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: 'ws', data_source: 'aibridge.manual_transcription'});
1135
- assert.calledOnceWithExactly(triggerSpy, {
1136
- isFinal: true,
1137
- transcriptId: '8d226d31-044a-8d11-cc39-cedbde183154',
1138
- transcripts: transcriptPayload.transcripts,
1139
- sender: 'ws',
1140
- source: 'aibridge.manual_transcription'
770
+ assert.calledOnceWithExactly(spy, voiceaPayload);
771
+
772
+ voiceaChannel.sendAnnouncement();
773
+
774
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
775
+ id: '24',
776
+ type: 'publishRequest',
777
+ recipients: [{route: 'binding'}],
778
+ headers: {},
779
+ data: {
780
+ clientPayload: {
781
+ version: 'v2',
782
+ },
783
+ eventType: 'relay.event',
784
+ relayType: 'client.annc',
785
+ },
786
+ trackingId: sinon.match.string,
1141
787
  });
1142
788
  });
1143
789
 
1144
- it('processes interim manual transcription from captioner', async () => {
1145
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1146
-
1147
- const transcriptPayload = {
1148
- id: "747d711d-3414-fd69-7081-e842649f2d28",
1149
- transcripts: [
1150
- {
1151
- text: "Good",
1152
- }
1153
- ],
1154
- type: "manual_caption_interim_result",
1155
- };
790
+ it('processes transcription interim results', () => {
791
+ const spy = sinon.spy();
792
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
1156
793
 
1157
- // eslint-disable-next-line no-underscore-dangle
1158
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1159
- headers: {from: '654321'},
1160
- data: {relayType: 'client.manual_transcription', transcriptPayload},
794
+ eventHandler({
795
+ sequenceNumber: 1,
796
+ headers: {},
797
+ data: {
798
+ relayType: 'voicea.transcription',
799
+ voiceaPayload: {
800
+ type: 'transcript_interim_results',
801
+ transcript_id: 'tid-1',
802
+ transcripts: [{text: 'Hello'}],
803
+ },
804
+ },
1161
805
  });
1162
806
 
1163
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: '654321', data_source: 'client.manual_transcription'});
1164
- assert.calledOnceWithExactly(triggerSpy, {
807
+ assert.calledOnceWithExactly(spy, {
1165
808
  isFinal: false,
1166
- transcriptId: '747d711d-3414-fd69-7081-e842649f2d28',
1167
- transcripts: transcriptPayload.transcripts,
1168
- sender: '654321',
1169
- source: 'client.manual_transcription'
809
+ transcriptId: 'tid-1',
810
+ transcripts: [{text: 'Hello'}],
1170
811
  });
1171
812
  });
1172
813
 
1173
- it('processes final manual transcription from captioner', async () => {
1174
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1175
-
1176
- const transcriptPayload = {
1177
- id: "8d226d31-044a-8d11-cc39-cedbde183154",
1178
- transcripts: [
1179
- {
1180
- text: "Good Morning",
1181
- start_millis: 10420,
1182
- end_millis: 11380,
1183
- }
1184
- ],
1185
- type: "manual_caption_final_result",
1186
- };
814
+ it('processes transcription final results', () => {
815
+ const spy = sinon.spy();
816
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
1187
817
 
1188
- // eslint-disable-next-line no-underscore-dangle
1189
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1190
- headers: {from: '654321'},
1191
- data: {relayType: 'client.manual_transcription', transcriptPayload},
818
+ eventHandler({
819
+ sequenceNumber: 1,
820
+ headers: {},
821
+ data: {
822
+ relayType: 'voicea.transcription',
823
+ voiceaPayload: {
824
+ type: 'transcript_final_result',
825
+ transcript_id: 'tid-2',
826
+ transcripts: [{text: 'Hello world', end_millis: 60000}],
827
+ },
828
+ },
1192
829
  });
1193
830
 
1194
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: '654321', data_source: 'client.manual_transcription'});
1195
- assert.calledOnceWithExactly(triggerSpy, {
1196
- isFinal: true,
1197
- transcriptId: '8d226d31-044a-8d11-cc39-cedbde183154',
1198
- transcripts: transcriptPayload.transcripts,
1199
- sender: '654321',
1200
- source: 'client.manual_transcription'
1201
- });
831
+ assert.calledOnce(spy);
832
+ const call = spy.getCall(0);
833
+ assert.equal(call.args[0].isFinal, true);
834
+ assert.equal(call.args[0].transcriptId, 'tid-2');
1202
835
  });
1203
- });
1204
836
 
1205
- describe("#getCaptionStatus", () => {
1206
- it('works correctly', () => {
1207
- voiceaService.captionStatus = "enabled"
1208
- assert.equal(voiceaService.getCaptionStatus(), "enabled");
1209
- });
1210
- });
837
+ it('processes caption language response success', () => {
838
+ const spy = sinon.spy();
839
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
1211
840
 
1212
- describe("#getAnnounceStatus", () => {
1213
- it('works correctly', () => {
1214
- voiceaService.announceStatus = "joined"
1215
- assert.equal(voiceaService.getAnnounceStatus(), "joined");
841
+ eventHandler({
842
+ sequenceNumber: 1,
843
+ headers: {},
844
+ data: {
845
+ relayType: 'voicea.transl.rsp',
846
+ voiceaPayload: {statusCode: 200},
847
+ },
848
+ });
849
+
850
+ assert.calledOnceWithExactly(spy, {statusCode: 200});
1216
851
  });
1217
- });
1218
852
 
1219
- describe('#onSpokenLanguageUpdate', () => {
1220
- it('should trigger SPOKEN_LANGUAGE_UPDATE event with correct languageCode', () => {
1221
- const triggerSpy = sinon.spy();
1222
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
853
+ it('processes caption language response error', () => {
854
+ const spy = sinon.spy();
855
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
1223
856
 
1224
- const languageCode = 'fr';
1225
- voiceaService.onSpokenLanguageUpdate(languageCode, '123');
1226
- assert.equal(voiceaService.currentSpokenLanguage, languageCode);
1227
- assert.calledOnceWithExactly(triggerSpy, {languageCode, meetingId: '123'});
857
+ eventHandler({
858
+ sequenceNumber: 1,
859
+ headers: {},
860
+ data: {
861
+ relayType: 'voicea.transl.rsp',
862
+ voiceaPayload: {statusCode: 400, errorCode: 400, message: 'Bad request'},
863
+ },
864
+ });
865
+
866
+ assert.calledOnceWithExactly(spy, {statusCode: 400, errorMessage: 'Bad request'});
1228
867
  });
1229
- });
1230
868
 
1231
- describe('#onCaptionServiceIdUpdate', () => {
1232
- let mockWebSocket;
869
+ it('processes manual transcription events', () => {
870
+ const spy = sinon.spy();
871
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, spy);
1233
872
 
1234
- beforeEach(() => {
1235
- mockWebSocket = new MockWebSocket();
1236
- voiceaService.webex.internal.llm.socket = mockWebSocket;
1237
- voiceaService.webex.internal.llm.isConnected.returns(true);
1238
- voiceaService.seqNum = 1;
873
+ eventHandler({
874
+ sequenceNumber: 1,
875
+ headers: {from: 'user-1'},
876
+ data: {
877
+ relayType: 'client.manual_transcription',
878
+ transcriptPayload: {
879
+ type: 'manual_caption_final_result',
880
+ id: 'manual-id',
881
+ transcripts: [{text: 'Manual caption'}],
882
+ },
883
+ },
884
+ });
885
+
886
+ assert.calledOnce(spy);
887
+ const call = spy.getCall(0);
888
+ assert.equal(call.args[0].isFinal, true);
889
+ assert.equal(call.args[0].transcriptId, 'manual-id');
1239
890
  });
1240
891
 
1241
- it('does nothing when serviceId is falsy', () => {
1242
- voiceaService.captionServiceId = 'existing-id';
1243
- voiceaService.currentCaptionLanguage = 'en';
892
+ it('processes highlight created events', () => {
893
+ const spy = sinon.spy();
894
+ voiceaChannel.on(EVENT_TRIGGERS.HIGHLIGHT_CREATED, spy);
1244
895
 
1245
- voiceaService.onCaptionServiceIdUpdate(undefined);
1246
- voiceaService.onCaptionServiceIdUpdate('');
896
+ eventHandler({
897
+ sequenceNumber: 1,
898
+ headers: {},
899
+ data: {
900
+ relayType: 'voicea.transcription',
901
+ voiceaPayload: {
902
+ type: 'highlight_created',
903
+ highlight: {
904
+ csis: [123],
905
+ highlight_id: 'h-1',
906
+ transcript: 'Highlighted text',
907
+ highlight_label: 'important',
908
+ highlight_source: 'user',
909
+ end_millis: 30000,
910
+ },
911
+ },
912
+ },
913
+ });
1247
914
 
1248
- assert.equal(voiceaService.captionServiceId, 'existing-id');
1249
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
915
+ assert.calledOnce(spy);
1250
916
  });
1251
917
 
1252
- it('sets captionServiceId when no currentCaptionLanguage', () => {
1253
- voiceaService.captionServiceId = undefined;
1254
- voiceaService.currentCaptionLanguage = undefined;
918
+ it('processes eva wake events', () => {
919
+ const spy = sinon.spy();
920
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
1255
921
 
1256
- voiceaService.onCaptionServiceIdUpdate('svc-new');
922
+ eventHandler({
923
+ sequenceNumber: 1,
924
+ headers: {},
925
+ data: {
926
+ relayType: 'voicea.transcription',
927
+ voiceaPayload: {type: 'eva_wake'},
928
+ },
929
+ });
1257
930
 
1258
- assert.equal(voiceaService.captionServiceId, 'svc-new');
1259
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
931
+ assert.calledOnceWithExactly(spy, {isListening: true});
1260
932
  });
1261
933
 
1262
- it('re-sends language when serviceId changes and currentCaptionLanguage is set', () => {
1263
- voiceaService.captionServiceId = 'old-svc';
1264
- voiceaService.currentCaptionLanguage = 'es';
1265
-
1266
- voiceaService.onCaptionServiceIdUpdate('new-svc');
934
+ it('processes eva cancel events', () => {
935
+ const spy = sinon.spy();
936
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
1267
937
 
1268
- assert.equal(voiceaService.captionServiceId, 'new-svc');
1269
- assert.calledOnce(voiceaService.webex.internal.llm.socket.send);
938
+ eventHandler({
939
+ sequenceNumber: 1,
940
+ headers: {},
941
+ data: {
942
+ relayType: 'voicea.transcription',
943
+ voiceaPayload: {type: 'eva_cancel'},
944
+ },
945
+ });
1270
946
 
1271
- const callArgs = voiceaService.webex.internal.llm.socket.send.getCall(0).args[0];
1272
- expect(callArgs).to.have.nested.property('headers.to', 'new-svc');
1273
- expect(callArgs).to.have.nested.property('data.clientPayload.translationLanguage', 'es');
947
+ assert.calledOnceWithExactly(spy, {isListening: false});
1274
948
  });
1275
949
 
1276
- it('does not re-send language when serviceId is unchanged', () => {
1277
- voiceaService.captionServiceId = 'same-svc';
1278
- voiceaService.currentCaptionLanguage = 'de';
950
+ it('processes eva thanks events', () => {
951
+ const spy = sinon.spy();
952
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
1279
953
 
1280
- voiceaService.onCaptionServiceIdUpdate('same-svc');
954
+ eventHandler({
955
+ sequenceNumber: 1,
956
+ headers: {},
957
+ data: {
958
+ relayType: 'voicea.transcription',
959
+ voiceaPayload: {type: 'eva_thanks', command_response: 'OK, noted'},
960
+ },
961
+ });
1281
962
 
1282
- assert.equal(voiceaService.captionServiceId, 'same-svc');
1283
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
963
+ assert.calledOnceWithExactly(spy, {isListening: false, text: 'OK, noted'});
1284
964
  });
1285
- });
1286
965
 
1287
- describe('#updateSubchannelSubscriptions', () => {
1288
- beforeEach(() => {
1289
- const mockWebSocket = new MockWebSocket();
966
+ it('processes language detected when in spoken languages', () => {
967
+ const spy = sinon.spy();
968
+ voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
1290
969
 
1291
- sinon.stub(voiceaService, 'getPublishTransport').returns({
1292
- socket: mockWebSocket,
1293
- datachannelUrl: 'mock-datachannel-uri',
970
+ // First set up spoken languages via announcement
971
+ eventHandler({
972
+ sequenceNumber: 1,
973
+ headers: {from: 'ws-service'},
974
+ data: {
975
+ relayType: 'voicea.annc',
976
+ voiceaPayload: {
977
+ ASR: {spoken_languages: ['en', 'es', 'fr']},
978
+ },
979
+ },
1294
980
  });
1295
981
 
1296
- voiceaService.seqNum = 1;
982
+ // Then emit language detected
983
+ eventHandler({
984
+ sequenceNumber: 2,
985
+ headers: {},
986
+ data: {
987
+ relayType: 'voicea.transcription',
988
+ voiceaPayload: {type: 'language_detected', language: 'es'},
989
+ },
990
+ });
1297
991
 
1298
- voiceaService.isLLMConnected = sinon.stub().returns(true);
1299
- voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
992
+ assert.calledOnceWithExactly(spy, {languageCode: 'es'});
1300
993
  });
1301
994
 
1302
- it('sends subchannelSubscriptionRequest with subscribe and unsubscribe lists', async () => {
1303
- await voiceaService.updateSubchannelSubscriptions({
1304
- subscribe: ['transcription'],
1305
- unsubscribe: ['polls'],
1306
- });
995
+ it('does not emit language detected when not in spoken languages', () => {
996
+ const spy = sinon.spy();
997
+ voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
1307
998
 
1308
- const socket = voiceaService.getPublishTransport().socket;
1309
-
1310
- sinon.assert.calledOnceWithExactly(
1311
- socket.send,
1312
- {
1313
- id: '1',
1314
- type: 'subchannelSubscriptionRequest',
1315
- data: {
1316
- datachannelUri: 'mock-datachannel-uri',
1317
- subscribe: ['transcription'],
1318
- unsubscribe: ['polls'],
999
+ // First set up spoken languages via announcement
1000
+ eventHandler({
1001
+ sequenceNumber: 1,
1002
+ headers: {from: 'ws-service'},
1003
+ data: {
1004
+ relayType: 'voicea.annc',
1005
+ voiceaPayload: {
1006
+ ASR: {spoken_languages: ['en', 'es']},
1319
1007
  },
1320
- trackingId: sinon.match.string,
1321
- }
1322
- );
1008
+ },
1009
+ });
1323
1010
 
1324
- sinon.assert.match(voiceaService.seqNum, 2);
1011
+ // Then emit language detected for unsupported language
1012
+ eventHandler({
1013
+ sequenceNumber: 2,
1014
+ headers: {},
1015
+ data: {
1016
+ relayType: 'voicea.transcription',
1017
+ voiceaPayload: {type: 'language_detected', language: 'de'},
1018
+ },
1019
+ });
1020
+
1021
+ assert.notCalled(spy);
1325
1022
  });
1023
+ });
1326
1024
 
1327
- it('sends empty arrays when no subscribe/unsubscribe provided', async () => {
1328
- await voiceaService.updateSubchannelSubscriptions({});
1025
+ describe('#switchLLMChannel', () => {
1026
+ it('switches to a new LLM channel and preserves caption state', async () => {
1027
+ // First enable captions
1028
+ voiceaChannel.keepTranscriptionSubscribed = true;
1029
+ voiceaChannel.currentSpokenLanguage = 'es';
1329
1030
 
1330
- const socket = voiceaService.getPublishTransport().socket;
1031
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1331
1032
 
1332
- sinon.assert.calledOnceWithExactly(
1333
- socket.send,
1334
- {
1335
- id: '1',
1336
- type: 'subchannelSubscriptionRequest',
1337
- data: {
1338
- datachannelUri: 'mock-datachannel-uri',
1339
- subscribe: [],
1340
- unsubscribe: [],
1341
- },
1342
- trackingId: sinon.match.string,
1343
- }
1344
- );
1033
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1345
1034
 
1346
- sinon.assert.match(voiceaService.seqNum, 2);
1347
- });
1035
+ // Should have unsubscribed from old channel
1036
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1348
1037
 
1349
- it('does nothing when LLM is not connected', async () => {
1350
- voiceaService.isLLMConnected = sinon.stub().returns(false);
1038
+ // Should have subscribed to new channel
1039
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1351
1040
 
1352
- await voiceaService.updateSubchannelSubscriptions({
1353
- subscribe: ['transcription'],
1354
- });
1041
+ // Should have reset announcement state to joining (since captions were on and it re-announced)
1042
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
1355
1043
 
1356
- const socket = voiceaService.getPublishTransport().socket;
1044
+ // turnOnCaptions sends both an announcement and a subchannel subscription
1045
+ assert.calledTwice(newMockLLMChannel.socket.send);
1357
1046
 
1358
- sinon.assert.notCalled(socket.send);
1359
- sinon.assert.match(voiceaService.seqNum, 1);
1047
+ // First call should be the announcement
1048
+ assert.calledWithExactly(newMockLLMChannel.socket.send.getCall(0), {
1049
+ id: '1',
1050
+ type: 'publishRequest',
1051
+ recipients: [{route: 'binding'}],
1052
+ headers: {},
1053
+ data: {
1054
+ clientPayload: {
1055
+ version: 'v2',
1056
+ },
1057
+ eventType: 'relay.event',
1058
+ relayType: 'client.annc',
1059
+ },
1060
+ trackingId: sinon.match.string,
1061
+ });
1360
1062
  });
1361
1063
 
1362
- it('does nothing when dataChannelToken is not enabled', async () => {
1363
- voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
1064
+ it('resolves after caption restoration completes', async () => {
1065
+ let resolveRequest;
1066
+ requestStub.returns(
1067
+ new Promise((resolve) => {
1068
+ resolveRequest = resolve;
1069
+ })
1070
+ );
1071
+ voiceaChannel.keepTranscriptionSubscribed = true;
1364
1072
 
1365
- await voiceaService.updateSubchannelSubscriptions({
1366
- subscribe: ['transcription'],
1073
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1074
+ const switchPromise = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1075
+ let hasSwitchResolved = false;
1076
+ switchPromise.then(() => {
1077
+ hasSwitchResolved = true;
1367
1078
  });
1368
1079
 
1369
- const socket = voiceaService.getPublishTransport().socket;
1080
+ await flushPromises();
1370
1081
 
1371
- sinon.assert.notCalled(socket.send);
1372
- sinon.assert.match(voiceaService.seqNum, 1);
1373
- });
1374
- });
1082
+ assert.isFalse(hasSwitchResolved);
1375
1083
 
1084
+ resolveRequest();
1085
+ await switchPromise;
1376
1086
 
1377
- describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
1378
- beforeEach(() => {
1379
- const mockWebSocket = new MockWebSocket();
1380
- voiceaService.webex.internal.llm.socket = mockWebSocket;
1087
+ assert.isTrue(hasSwitchResolved);
1088
+ });
1381
1089
 
1382
- voiceaService.webex.internal.llm.getDatachannelUrl = sinon.stub().returns('mock-datachannel-uri');
1090
+ it('skips switch when already on the same channel', async () => {
1091
+ // Enable captions to verify they are NOT re-enabled on no-op switch
1092
+ voiceaChannel.keepTranscriptionSubscribed = true;
1093
+ voiceaChannel.currentSpokenLanguage = 'es';
1383
1094
 
1384
- voiceaService.seqNum = 1;
1095
+ // Switch to the same channel that voiceaChannel is already using
1096
+ await voiceaChannel.switchLLMChannel(mockLLMChannel);
1385
1097
 
1386
- voiceaService.isLLMConnected = sinon.stub().returns(true);
1387
- voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
1098
+ // Should NOT have unsubscribed (no-op)
1099
+ assert.notCalled(mockLLMChannel.off);
1388
1100
 
1389
- sinon.spy(voiceaService, 'updateSubchannelSubscriptions');
1101
+ // Should NOT have re-subscribed
1102
+ // The 'on' was already called in the constructor, but no NEW calls should happen
1103
+ // Since constructor already called 'on', we check it wasn't called again after construction
1104
+ // The mock was created fresh, so we just verify no additional sends
1105
+ assert.notCalled(mockLLMChannel.socket.send);
1390
1106
  });
1391
1107
 
1392
- afterEach(() => {
1393
- sinon.restore();
1394
- });
1108
+ it('does not turn on captions if they were not on before switching', async () => {
1109
+ // Captions are off
1110
+ voiceaChannel.keepTranscriptionSubscribed = false;
1395
1111
 
1396
- it('updates caption intent and forwards subscribe/unsubscribe to updateSubchannelSubscriptions', async () => {
1397
- await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1398
- {
1399
- subscribe: ['transcription'],
1400
- unsubscribe: ['polls'],
1401
- },
1402
- true
1403
- );
1112
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1404
1113
 
1405
- assert.equal(voiceaService.keepTranscriptionSubscribed, true);
1114
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1406
1115
 
1407
- assert.calledOnceWithExactly(
1408
- voiceaService.updateSubchannelSubscriptions,
1409
- {
1410
- subscribe: ['transcription'],
1411
- unsubscribe: ['polls'],
1412
- }
1413
- );
1414
- });
1116
+ // Should have unsubscribed from old channel
1117
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1415
1118
 
1416
- it('sets caption intent to false when isCCBoxOpen is false', async () => {
1417
- await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1418
- { subscribe: ['transcription'] },
1419
- false
1420
- );
1119
+ // Should have subscribed to new channel
1120
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1421
1121
 
1422
- assert.equal(voiceaService.keepTranscriptionSubscribed, false);
1122
+ // Should NOT have sent any messages (no announcement for captions)
1123
+ assert.notCalled(newMockLLMChannel.socket.send);
1423
1124
 
1424
- assert.calledOnceWithExactly(
1425
- voiceaService.updateSubchannelSubscriptions,
1426
- { subscribe: ['transcription'] }
1427
- );
1125
+ // State should be idle since captions weren't re-enabled
1126
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
1428
1127
  });
1429
1128
 
1430
- it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
1431
- await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
1129
+ it('handles case when not previously subscribed to events', async () => {
1130
+ // Create a new channel that hasn't subscribed to events
1131
+ const freshVoiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
1132
+ freshVoiceaChannel.hasSubscribedToEvents = false;
1432
1133
 
1433
- assert.equal(voiceaService.keepTranscriptionSubscribed, true);
1134
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1434
1135
 
1435
- assert.calledOnceWithExactly(
1436
- voiceaService.updateSubchannelSubscriptions,
1437
- {}
1438
- );
1439
- });
1136
+ await freshVoiceaChannel.switchLLMChannel(newMockLLMChannel);
1440
1137
 
1441
- it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
1442
- voiceaService.isLLMConnected = sinon.stub().returns(false);
1138
+ // Should NOT have tried to unsubscribe from old channel (wasn't subscribed)
1139
+ assert.neverCalledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1443
1140
 
1444
- await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1445
- { subscribe: ['transcription'] },
1446
- true
1447
- );
1141
+ // Should have subscribed to new channel
1142
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1143
+ });
1448
1144
 
1449
- assert.equal(voiceaService.keepTranscriptionSubscribed, true);
1145
+ it('switches from undefined llmChannel to a valid one', async () => {
1146
+ // Create a channel without llmChannel
1147
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
1450
1148
 
1451
- assert.calledOnceWithExactly(
1452
- voiceaService.updateSubchannelSubscriptions,
1453
- { subscribe: ['transcription'] }
1454
- );
1455
- });
1456
- });
1149
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1457
1150
 
1458
- describe('#multiple llm connections', () => {
1459
- let defaultSocket;
1460
- let practiceSocket;
1461
- let isPracticeSessionConnected;
1151
+ await channelWithoutLLM.switchLLMChannel(newMockLLMChannel);
1462
1152
 
1463
- beforeEach(() => {
1464
- defaultSocket = new MockWebSocket();
1465
- practiceSocket = new MockWebSocket();
1466
- isPracticeSessionConnected = true;
1153
+ // Should have subscribed to new channel
1154
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1467
1155
 
1468
- voiceaService.webex.internal.llm.socket = defaultSocket;
1469
- voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
1470
- channel === LLM_PRACTICE_SESSION ? isPracticeSessionConnected : true
1471
- );
1472
- voiceaService.webex.internal.llm.getSocket.callsFake((channel) =>
1473
- channel === LLM_PRACTICE_SESSION ? practiceSocket : undefined
1474
- );
1475
- voiceaService.webex.internal.llm.getBinding.callsFake((channel) =>
1476
- channel === LLM_PRACTICE_SESSION ? 'practice-binding' : 'default-binding'
1477
- );
1478
- voiceaService.seqNum = 1;
1156
+ // isLLMConnected should now return true
1157
+ assert.equal(channelWithoutLLM.isLLMConnected(), true);
1479
1158
  });
1480
1159
 
1481
- it('sendAnnouncement uses the practice session socket and binding when available', () => {
1482
- voiceaService.announceStatus = 'idle';
1160
+ it('defers caption restoration when new channel is not connected', async () => {
1161
+ // Enable captions
1162
+ voiceaChannel.keepTranscriptionSubscribed = true;
1163
+ voiceaChannel.currentSpokenLanguage = 'fr';
1483
1164
 
1484
- voiceaService.sendAnnouncement();
1165
+ // Create a new channel that is NOT connected yet
1166
+ const newMockLLMChannel = createMockLLMChannel({
1167
+ isConnected: false,
1168
+ locusUrl: 'newLocusUrl',
1169
+ });
1485
1170
 
1486
- assert.calledOnce(practiceSocket.send);
1487
- assert.notCalled(defaultSocket.send);
1171
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1488
1172
 
1489
- const sent = practiceSocket.send.getCall(0).args[0];
1490
- expect(sent).to.have.nested.property('recipients[0].route', 'practice-binding');
1173
+ // Should have subscribed to new channel events
1174
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1175
+
1176
+ // Should have registered a 'once' listener for 'online' event
1177
+ assert.calledWith(newMockLLMChannel.once, 'online', sinon.match.func);
1178
+
1179
+ // Should NOT have sent any messages yet (waiting for connection)
1180
+ assert.notCalled(newMockLLMChannel.socket.send);
1491
1181
  });
1492
1182
 
1493
- it('sendAnnouncement falls back to the default socket and binding when the practice session is not connected', () => {
1494
- voiceaService.announceStatus = 'idle';
1495
- isPracticeSessionConnected = false;
1183
+ it('restores captions when deferred channel comes online', async () => {
1184
+ // Enable captions
1185
+ voiceaChannel.keepTranscriptionSubscribed = true;
1186
+ voiceaChannel.currentSpokenLanguage = 'de';
1496
1187
 
1497
- voiceaService.sendAnnouncement();
1188
+ // Create a new channel that is NOT connected yet
1189
+ const newMockLLMChannel = createMockLLMChannel({
1190
+ isConnected: false,
1191
+ locusUrl: 'newLocusUrl',
1192
+ });
1498
1193
 
1499
- assert.calledOnce(defaultSocket.send);
1500
- assert.notCalled(practiceSocket.send);
1194
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1501
1195
 
1502
- const sent = defaultSocket.send.getCall(0).args[0];
1503
- expect(sent).to.have.nested.property('recipients[0].route', 'default-binding');
1504
- });
1196
+ // Get the 'online' listener that was registered
1197
+ const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
1505
1198
 
1506
- it('requestLanguage uses the practice session socket and binding when available', () => {
1507
- voiceaService.requestLanguage('fr');
1199
+ // Simulate channel coming online
1200
+ newMockLLMChannel.isConnected.returns(true);
1201
+ onlineListener();
1508
1202
 
1509
- assert.calledOnce(practiceSocket.send);
1510
- assert.notCalled(defaultSocket.send);
1203
+ // Wait for async turnOnCaptions to complete
1204
+ await flushPromises();
1511
1205
 
1512
- const sent = practiceSocket.send.getCall(0).args[0];
1513
- expect(sent).to.have.nested.property('recipients[0].route', 'practice-binding');
1514
- expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1206
+ // Now it should have sent messages (announcement + subchannel subscription)
1207
+ assert.calledTwice(newMockLLMChannel.socket.send);
1515
1208
  });
1516
1209
 
1517
- it('requestLanguage falls back to the default socket and binding when the practice session is not connected', () => {
1518
- isPracticeSessionConnected = false;
1210
+ it('removes pending online listener when deregisterEvents is called', async () => {
1211
+ // Enable captions
1212
+ voiceaChannel.keepTranscriptionSubscribed = true;
1519
1213
 
1520
- voiceaService.requestLanguage('fr');
1214
+ // Create a new channel that is NOT connected yet
1215
+ const newMockLLMChannel = createMockLLMChannel({
1216
+ isConnected: false,
1217
+ locusUrl: 'newLocusUrl',
1218
+ });
1521
1219
 
1522
- assert.calledOnce(defaultSocket.send);
1523
- assert.notCalled(practiceSocket.send);
1220
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1524
1221
 
1525
- const sent = defaultSocket.send.getCall(0).args[0];
1526
- expect(sent).to.have.nested.property('recipients[0].route', 'default-binding');
1527
- expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1222
+ // Get the 'online' listener that was registered
1223
+ const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
1224
+
1225
+ // Now deregister events
1226
+ voiceaChannel.deregisterEvents();
1227
+
1228
+ // Should have removed the 'online' listener
1229
+ assert.calledWith(newMockLLMChannel.off, 'online', onlineListener);
1528
1230
  });
1529
1231
 
1530
- it('sendManualClosedCaption uses the practice session socket and binding when available', () => {
1531
- voiceaService.sendManualClosedCaption('caption', 123, [456], true);
1232
+ it('removes old pending online listener when switching channels again', async () => {
1233
+ // Enable captions
1234
+ voiceaChannel.keepTranscriptionSubscribed = true;
1532
1235
 
1533
- assert.calledOnce(practiceSocket.send);
1534
- assert.notCalled(defaultSocket.send);
1236
+ // Create first new channel that is NOT connected
1237
+ const firstNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'firstUrl'});
1535
1238
 
1536
- const sent = practiceSocket.send.getCall(0).args[0];
1537
- expect(sent).to.have.nested.property('recipients[0].route', 'practice-binding');
1538
- expect(sent).to.have.nested.property(
1539
- 'data.transcriptPayload.type',
1540
- 'manual_caption_final_result'
1541
- );
1542
- });
1239
+ await voiceaChannel.switchLLMChannel(firstNewChannel);
1543
1240
 
1544
- it('sendManualClosedCaption falls back to the default socket and binding when the practice session is not connected', () => {
1545
- isPracticeSessionConnected = false;
1241
+ // Get the 'online' listener registered on first channel
1242
+ const firstOnlineListener = firstNewChannel.once.getCall(0).args[1];
1546
1243
 
1547
- voiceaService.sendManualClosedCaption('caption', 123, [456], false);
1244
+ // Now switch to another channel (also not connected)
1245
+ const secondNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'secondUrl'});
1548
1246
 
1549
- assert.calledOnce(defaultSocket.send);
1550
- assert.notCalled(practiceSocket.send);
1247
+ await voiceaChannel.switchLLMChannel(secondNewChannel);
1551
1248
 
1552
- const sent = defaultSocket.send.getCall(0).args[0];
1553
- expect(sent).to.have.nested.property('recipients[0].route', 'default-binding');
1554
- expect(sent).to.have.nested.property(
1555
- 'data.transcriptPayload.type',
1556
- 'manual_caption_interim_result'
1557
- );
1249
+ // Should have removed the 'online' listener from first channel
1250
+ assert.calledWith(firstNewChannel.off, 'online', firstOnlineListener);
1251
+
1252
+ // Should have registered a new 'online' listener on second channel
1253
+ assert.calledWith(secondNewChannel.once, 'online', sinon.match.func);
1558
1254
  });
1559
1255
 
1560
- it('processes relay events from the practice session channel', async () => {
1561
- const announcementSpy = sinon.spy(voiceaService, 'processAnnouncementMessage');
1256
+ it('does not duplicate caption HTTP requests on concurrent same-channel switches', async () => {
1257
+ // Enable captions
1258
+ voiceaChannel.keepTranscriptionSubscribed = true;
1259
+ voiceaChannel.currentSpokenLanguage = 'en';
1562
1260
 
1563
- voiceaService.listenToEvents();
1261
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1564
1262
 
1565
- // eslint-disable-next-line no-underscore-dangle
1566
- await voiceaService.webex.internal.llm._emit(`event:relay.event:${LLM_PRACTICE_SESSION}`, {
1567
- headers: {from: 'svc-practice'},
1568
- data: {
1569
- relayType: 'voicea.annc',
1570
- voiceaPayload: {
1571
- translation: {allowed_languages: ['en'], max_languages: 1},
1572
- ASR: {spoken_languages: ['en']},
1573
- },
1574
- },
1575
- sequenceNumber: 10,
1576
- });
1263
+ // Simulate concurrent switches to the same channel
1264
+ const switch1 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1265
+ const switch2 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1266
+
1267
+ await Promise.all([switch1, switch2]);
1268
+
1269
+ // Wait for fire-and-forget turnOnCaptions to complete
1270
+ await new Promise((resolve) => setTimeout(resolve, 0));
1271
+
1272
+ // Should only have sent 2 messages total (announcement + subchannel subscription)
1273
+ // NOT 4 messages (which would indicate duplicate switches)
1274
+ assert.calledTwice(newMockLLMChannel.socket.send);
1275
+ });
1276
+ });
1277
+
1278
+ describe('#requireLLMChannel', () => {
1279
+ it('throws when llmChannel is undefined', () => {
1280
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
1281
+
1282
+ assert.throws(
1283
+ () => channelWithoutLLM.sendAnnouncement(),
1284
+ 'VoiceaChannel: LLM channel not available'
1285
+ );
1286
+ });
1577
1287
 
1578
- assert.calledOnce(announcementSpy);
1579
- assert.equal(voiceaService.captionServiceId, 'svc-practice');
1288
+ it('does not throw when llmChannel is defined', () => {
1289
+ // voiceaChannel has a mockLLMChannel, so this should not throw
1290
+ assert.doesNotThrow(() => voiceaChannel.sendAnnouncement());
1580
1291
  });
1581
1292
  });
1582
1293
  });