@webex/internal-plugin-voicea 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.1

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.
@@ -7,7 +7,11 @@ import Mercury from '@webex/internal-plugin-mercury';
7
7
  import LLMChannel from '@webex/internal-plugin-llm';
8
8
 
9
9
  import VoiceaService from '../../../src/index';
10
- import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
10
+ import {
11
+ EVENT_TRIGGERS,
12
+ LLM_PRACTICE_SESSION,
13
+ TOGGLE_MANUAL_CAPTION_STATUS,
14
+ } from '../../../src/constants';
11
15
 
12
16
  describe('plugin-voicea', () => {
13
17
  const locusUrl = 'locusUrl';
@@ -28,6 +32,7 @@ describe('plugin-voicea', () => {
28
32
  voiceaService.connect = sinon.stub().resolves(true);
29
33
  voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true);
30
34
  voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined);
35
+ voiceaService.webex.internal.llm.getSocket = sinon.stub().returns(undefined);
31
36
  voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl);
32
37
 
33
38
  voiceaService.request = sinon.stub().resolves({
@@ -87,7 +92,34 @@ describe('plugin-voicea', () => {
87
92
 
88
93
  voiceaService.sendAnnouncement();
89
94
 
90
- assert.calledOnceWithExactly(spy, 'event:relay.event', sinon.match.func);
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
+ 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';
106
+
107
+ voiceaService.sendAnnouncement();
108
+
109
+ assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
110
+ id: '1',
111
+ type: 'publishRequest',
112
+ recipients: {route: undefined},
113
+ headers: {to: 'svc-123'},
114
+ data: {
115
+ clientPayload: {
116
+ version: 'v2',
117
+ },
118
+ eventType: 'relay.event',
119
+ relayType: 'client.annc',
120
+ },
121
+ trackingId: sinon.match.string,
122
+ });
91
123
  });
92
124
  });
93
125
 
@@ -189,32 +221,32 @@ describe('plugin-voicea', () => {
189
221
  assert.notCalled(voiceaService.webex.internal.llm.socket.send);
190
222
  });
191
223
  });
192
-
193
224
  describe('#deregisterEvents', () => {
194
225
  beforeEach(async () => {
195
226
  const mockWebSocket = new MockWebSocket();
196
-
197
227
  voiceaService.webex.internal.llm.socket = mockWebSocket;
228
+ voiceaService.isCaptionBoxOn = true;
198
229
  });
199
230
 
200
- it('deregisters voicea service', async () => {
231
+ it('deregisters voicea service and resets caption state', async () => {
201
232
  voiceaService.listenToEvents();
202
233
  await voiceaService.toggleTranscribing(true);
203
234
 
204
- // eslint-disable-next-line no-underscore-dangle
205
235
  voiceaService.webex.internal.llm._emit('event:relay.event', {
206
236
  headers: {from: 'ws'},
207
237
  data: {relayType: 'voicea.annc', voiceaPayload: {}},
208
238
  });
209
239
 
210
240
  assert.equal(voiceaService.areCaptionsEnabled, true);
211
- assert.equal(voiceaService.vmcDeviceId, 'ws');
241
+ assert.equal(voiceaService.captionServiceId, 'ws');
242
+ assert.equal(voiceaService.isCaptionBoxOn, true);
212
243
 
213
244
  voiceaService.deregisterEvents();
214
245
  assert.equal(voiceaService.areCaptionsEnabled, false);
215
- assert.equal(voiceaService.vmcDeviceId, undefined);
246
+ assert.equal(voiceaService.captionServiceId, undefined);
216
247
  assert.equal(voiceaService.announceStatus, 'idle');
217
248
  assert.equal(voiceaService.captionStatus, 'idle');
249
+ assert.equal(voiceaService.isCaptionBoxOn, false);
218
250
  });
219
251
  });
220
252
  describe('#processAnnouncementMessage', () => {
@@ -244,7 +276,7 @@ describe('plugin-voicea', () => {
244
276
  });
245
277
  });
246
278
 
247
- it('works on non-empty payload', async () => {
279
+ it('works on empty payload', async () => {
248
280
  const spy = sinon.spy();
249
281
 
250
282
  voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
@@ -286,6 +318,28 @@ describe('plugin-voicea', () => {
286
318
  trackingId: sinon.match.string,
287
319
  });
288
320
  });
321
+
322
+ it('uses captionServiceId as "to" header when set', () => {
323
+ voiceaService.captionServiceId = 'svc-456';
324
+
325
+ voiceaService.requestLanguage('fr');
326
+
327
+ assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
328
+ id: '1',
329
+ type: 'publishRequest',
330
+ recipients: {route: undefined},
331
+ headers: {to: 'svc-456'},
332
+ data: {
333
+ clientPayload: {
334
+ translationLanguage: 'fr',
335
+ id: sinon.match.string,
336
+ },
337
+ eventType: 'relay.event',
338
+ relayType: 'voicea.transl.req',
339
+ },
340
+ trackingId: sinon.match.string,
341
+ });
342
+ });
289
343
  });
290
344
 
291
345
  describe('#setSpokenLanguage', () => {
@@ -354,6 +408,7 @@ describe('plugin-voicea', () => {
354
408
 
355
409
  it('turns on captions', async () => {
356
410
  const announcementSpy = sinon.spy(voiceaService, 'announce');
411
+ const updateSubchannelSubscriptionsAndSyncCaptionStateSpy = sinon.spy(voiceaService, 'updateSubchannelSubscriptionsAndSyncCaptionState');
357
412
 
358
413
  const triggerSpy = sinon.spy();
359
414
 
@@ -374,6 +429,11 @@ describe('plugin-voicea', () => {
374
429
  assert.calledOnceWithExactly(triggerSpy);
375
430
 
376
431
  assert.calledOnce(announcementSpy);
432
+ assert.calledOnceWithExactly(
433
+ updateSubchannelSubscriptionsAndSyncCaptionStateSpy,
434
+ { subscribe: ['transcription'] },
435
+ true
436
+ );
377
437
  });
378
438
 
379
439
  it("should handle request fail", async () => {
@@ -408,17 +468,61 @@ describe('plugin-voicea', () => {
408
468
  });
409
469
  });
410
470
 
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);
478
+ });
479
+
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
483
+ );
484
+
485
+ assert.equal(voiceaService.isLLMConnected(), true);
486
+ });
487
+
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);
492
+ });
493
+ });
494
+
495
+ describe('#getIsCaptionBoxOn', () => {
496
+ beforeEach(() => {
497
+ voiceaService.isCaptionBoxOn = false;
498
+ });
499
+
500
+ it('returns false when captions are disabled', () => {
501
+ voiceaService.isCaptionBoxOn = false;
502
+
503
+ const result = voiceaService.getIsCaptionBoxOn();
504
+
505
+ assert.equal(result, false);
506
+ });
507
+
508
+ it('returns true when captions are enabled', () => {
509
+ voiceaService.isCaptionBoxOn = true;
510
+
511
+ const result = voiceaService.getIsCaptionBoxOn();
512
+
513
+ assert.equal(result, true);
514
+ });
515
+ });
516
+
411
517
  describe("#announce", () => {
412
- let isAnnounceProcessing, sendAnnouncement;
518
+ let isAnnounceProcessed, sendAnnouncement;
413
519
  beforeEach(() => {
414
- voiceaService.webex.internal.llm.isConnected.returns(true);
415
520
  sendAnnouncement = sinon.stub(voiceaService, 'sendAnnouncement');
416
- isAnnounceProcessing = sinon.stub(voiceaService, 'isAnnounceProcessing').returns(false)
521
+ isAnnounceProcessed = sinon.stub(voiceaService, 'isAnnounceProcessed').returns(false)
417
522
  });
418
523
 
419
524
  afterEach(() => {
420
- voiceaService.webex.internal.llm.isConnected.returns(true);
421
- isAnnounceProcessing.restore();
525
+ isAnnounceProcessed.restore();
422
526
  sendAnnouncement.restore();
423
527
  });
424
528
 
@@ -433,8 +537,18 @@ describe('plugin-voicea', () => {
433
537
  assert.notCalled(sendAnnouncement);
434
538
  });
435
539
 
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
543
+ );
544
+
545
+ voiceaService.announce();
546
+
547
+ assert.calledOnce(sendAnnouncement);
548
+ });
549
+
436
550
  it('should not announce duplicate', () => {
437
- isAnnounceProcessing.returns(true);
551
+ isAnnounceProcessed.returns(true);
438
552
  voiceaService.announce();
439
553
  assert.notCalled(sendAnnouncement);
440
554
  })
@@ -463,13 +577,11 @@ describe('plugin-voicea', () => {
463
577
  beforeEach(() => {
464
578
  requestTurnOnCaptions = sinon.stub(voiceaService, 'requestTurnOnCaptions');
465
579
  voiceaService.captionStatus = 'idle';
466
- voiceaService.webex.internal.llm.isConnected.returns(true);
467
580
  });
468
581
 
469
582
  afterEach(() => {
470
583
  requestTurnOnCaptions.restore();
471
584
  voiceaService.captionStatus = 'idle';
472
- voiceaService.webex.internal.llm.isConnected.returns(true);
473
585
  });
474
586
 
475
587
  it('call request turn on captions', () => {
@@ -478,13 +590,27 @@ describe('plugin-voicea', () => {
478
590
  assert.calledOnce(requestTurnOnCaptions);
479
591
  });
480
592
 
481
- it("turns on captions before llm connected", () => {
593
+ it('throws before turning on captions when llm is not connected', async () => {
482
594
  voiceaService.captionStatus = 'idle';
483
- voiceaService.webex.internal.llm.isConnected.returns(true);
484
- // assert.throws(() => voiceaService.turnOnCaptions(), "can not turn on captions before llm connected");
595
+ voiceaService.webex.internal.llm.isConnected.returns(false);
596
+
597
+ await assert.isRejected(
598
+ voiceaService.turnOnCaptions(),
599
+ 'can not turn on captions before llm connected'
600
+ );
485
601
  assert.notCalled(requestTurnOnCaptions);
486
602
  });
487
603
 
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
+ );
608
+
609
+ voiceaService.turnOnCaptions();
610
+
611
+ assert.calledOnce(requestTurnOnCaptions);
612
+ });
613
+
488
614
  it('should not turn on duplicate when processing', () => {
489
615
  voiceaService.captionStatus = 'sending';
490
616
  voiceaService.turnOnCaptions();
@@ -1101,5 +1227,357 @@ describe('plugin-voicea', () => {
1101
1227
  assert.calledOnceWithExactly(triggerSpy, {languageCode, meetingId: '123'});
1102
1228
  });
1103
1229
  });
1230
+
1231
+ describe('#onCaptionServiceIdUpdate', () => {
1232
+ let mockWebSocket;
1233
+
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;
1239
+ });
1240
+
1241
+ it('does nothing when serviceId is falsy', () => {
1242
+ voiceaService.captionServiceId = 'existing-id';
1243
+ voiceaService.currentCaptionLanguage = 'en';
1244
+
1245
+ voiceaService.onCaptionServiceIdUpdate(undefined);
1246
+ voiceaService.onCaptionServiceIdUpdate('');
1247
+
1248
+ assert.equal(voiceaService.captionServiceId, 'existing-id');
1249
+ assert.notCalled(voiceaService.webex.internal.llm.socket.send);
1250
+ });
1251
+
1252
+ it('sets captionServiceId when no currentCaptionLanguage', () => {
1253
+ voiceaService.captionServiceId = undefined;
1254
+ voiceaService.currentCaptionLanguage = undefined;
1255
+
1256
+ voiceaService.onCaptionServiceIdUpdate('svc-new');
1257
+
1258
+ assert.equal(voiceaService.captionServiceId, 'svc-new');
1259
+ assert.notCalled(voiceaService.webex.internal.llm.socket.send);
1260
+ });
1261
+
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');
1267
+
1268
+ assert.equal(voiceaService.captionServiceId, 'new-svc');
1269
+ assert.calledOnce(voiceaService.webex.internal.llm.socket.send);
1270
+
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');
1274
+ });
1275
+
1276
+ it('does not re-send language when serviceId is unchanged', () => {
1277
+ voiceaService.captionServiceId = 'same-svc';
1278
+ voiceaService.currentCaptionLanguage = 'de';
1279
+
1280
+ voiceaService.onCaptionServiceIdUpdate('same-svc');
1281
+
1282
+ assert.equal(voiceaService.captionServiceId, 'same-svc');
1283
+ assert.notCalled(voiceaService.webex.internal.llm.socket.send);
1284
+ });
1285
+ });
1286
+
1287
+ describe('#updateSubchannelSubscriptions', () => {
1288
+ beforeEach(() => {
1289
+ const mockWebSocket = new MockWebSocket();
1290
+
1291
+ sinon.stub(voiceaService, 'getPublishTransport').returns({
1292
+ socket: mockWebSocket,
1293
+ datachannelUrl: 'mock-datachannel-uri',
1294
+ });
1295
+
1296
+ voiceaService.seqNum = 1;
1297
+
1298
+ voiceaService.isLLMConnected = sinon.stub().returns(true);
1299
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
1300
+ });
1301
+
1302
+ it('sends subchannelSubscriptionRequest with subscribe and unsubscribe lists', async () => {
1303
+ await voiceaService.updateSubchannelSubscriptions({
1304
+ subscribe: ['transcription'],
1305
+ unsubscribe: ['polls'],
1306
+ });
1307
+
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'],
1319
+ },
1320
+ trackingId: sinon.match.string,
1321
+ }
1322
+ );
1323
+
1324
+ sinon.assert.match(voiceaService.seqNum, 2);
1325
+ });
1326
+
1327
+ it('sends empty arrays when no subscribe/unsubscribe provided', async () => {
1328
+ await voiceaService.updateSubchannelSubscriptions({});
1329
+
1330
+ const socket = voiceaService.getPublishTransport().socket;
1331
+
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
+ );
1345
+
1346
+ sinon.assert.match(voiceaService.seqNum, 2);
1347
+ });
1348
+
1349
+ it('does nothing when LLM is not connected', async () => {
1350
+ voiceaService.isLLMConnected = sinon.stub().returns(false);
1351
+
1352
+ await voiceaService.updateSubchannelSubscriptions({
1353
+ subscribe: ['transcription'],
1354
+ });
1355
+
1356
+ const socket = voiceaService.getPublishTransport().socket;
1357
+
1358
+ sinon.assert.notCalled(socket.send);
1359
+ sinon.assert.match(voiceaService.seqNum, 1);
1360
+ });
1361
+
1362
+ it('does nothing when dataChannelToken is not enabled', async () => {
1363
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
1364
+
1365
+ await voiceaService.updateSubchannelSubscriptions({
1366
+ subscribe: ['transcription'],
1367
+ });
1368
+
1369
+ const socket = voiceaService.getPublishTransport().socket;
1370
+
1371
+ sinon.assert.notCalled(socket.send);
1372
+ sinon.assert.match(voiceaService.seqNum, 1);
1373
+ });
1374
+ });
1375
+
1376
+
1377
+ describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
1378
+ beforeEach(() => {
1379
+ const mockWebSocket = new MockWebSocket();
1380
+ voiceaService.webex.internal.llm.socket = mockWebSocket;
1381
+
1382
+ voiceaService.webex.internal.llm.getDatachannelUrl = sinon.stub().returns('mock-datachannel-uri');
1383
+
1384
+ voiceaService.seqNum = 1;
1385
+
1386
+ voiceaService.isLLMConnected = sinon.stub().returns(true);
1387
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
1388
+
1389
+ sinon.spy(voiceaService, 'updateSubchannelSubscriptions');
1390
+ });
1391
+
1392
+ afterEach(() => {
1393
+ sinon.restore();
1394
+ });
1395
+
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
+ );
1404
+
1405
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1406
+
1407
+ assert.calledOnceWithExactly(
1408
+ voiceaService.updateSubchannelSubscriptions,
1409
+ {
1410
+ subscribe: ['transcription'],
1411
+ unsubscribe: ['polls'],
1412
+ }
1413
+ );
1414
+ });
1415
+
1416
+ it('sets caption intent to false when isCCBoxOpen is false', async () => {
1417
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1418
+ { subscribe: ['transcription'] },
1419
+ false
1420
+ );
1421
+
1422
+ assert.equal(voiceaService.isCaptionBoxOn, false);
1423
+
1424
+ assert.calledOnceWithExactly(
1425
+ voiceaService.updateSubchannelSubscriptions,
1426
+ { subscribe: ['transcription'] }
1427
+ );
1428
+ });
1429
+
1430
+ it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
1431
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
1432
+
1433
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1434
+
1435
+ assert.calledOnceWithExactly(
1436
+ voiceaService.updateSubchannelSubscriptions,
1437
+ {}
1438
+ );
1439
+ });
1440
+
1441
+ it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
1442
+ voiceaService.isLLMConnected = sinon.stub().returns(false);
1443
+
1444
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1445
+ { subscribe: ['transcription'] },
1446
+ true
1447
+ );
1448
+
1449
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1450
+
1451
+ assert.calledOnceWithExactly(
1452
+ voiceaService.updateSubchannelSubscriptions,
1453
+ { subscribe: ['transcription'] }
1454
+ );
1455
+ });
1456
+ });
1457
+
1458
+ describe('#multiple llm connections', () => {
1459
+ let defaultSocket;
1460
+ let practiceSocket;
1461
+ let isPracticeSessionConnected;
1462
+
1463
+ beforeEach(() => {
1464
+ defaultSocket = new MockWebSocket();
1465
+ practiceSocket = new MockWebSocket();
1466
+ isPracticeSessionConnected = true;
1467
+
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;
1479
+ });
1480
+
1481
+ it('sendAnnouncement uses the practice session socket and binding when available', () => {
1482
+ voiceaService.announceStatus = 'idle';
1483
+
1484
+ voiceaService.sendAnnouncement();
1485
+
1486
+ assert.calledOnce(practiceSocket.send);
1487
+ assert.notCalled(defaultSocket.send);
1488
+
1489
+ const sent = practiceSocket.send.getCall(0).args[0];
1490
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1491
+ });
1492
+
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;
1496
+
1497
+ voiceaService.sendAnnouncement();
1498
+
1499
+ assert.calledOnce(defaultSocket.send);
1500
+ assert.notCalled(practiceSocket.send);
1501
+
1502
+ const sent = defaultSocket.send.getCall(0).args[0];
1503
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1504
+ });
1505
+
1506
+ it('requestLanguage uses the practice session socket and binding when available', () => {
1507
+ voiceaService.requestLanguage('fr');
1508
+
1509
+ assert.calledOnce(practiceSocket.send);
1510
+ assert.notCalled(defaultSocket.send);
1511
+
1512
+ const sent = practiceSocket.send.getCall(0).args[0];
1513
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1514
+ expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1515
+ });
1516
+
1517
+ it('requestLanguage falls back to the default socket and binding when the practice session is not connected', () => {
1518
+ isPracticeSessionConnected = false;
1519
+
1520
+ voiceaService.requestLanguage('fr');
1521
+
1522
+ assert.calledOnce(defaultSocket.send);
1523
+ assert.notCalled(practiceSocket.send);
1524
+
1525
+ const sent = defaultSocket.send.getCall(0).args[0];
1526
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1527
+ expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1528
+ });
1529
+
1530
+ it('sendManualClosedCaption uses the practice session socket and binding when available', () => {
1531
+ voiceaService.sendManualClosedCaption('caption', 123, [456], true);
1532
+
1533
+ assert.calledOnce(practiceSocket.send);
1534
+ assert.notCalled(defaultSocket.send);
1535
+
1536
+ const sent = practiceSocket.send.getCall(0).args[0];
1537
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1538
+ expect(sent).to.have.nested.property(
1539
+ 'data.transcriptPayload.type',
1540
+ 'manual_caption_final_result'
1541
+ );
1542
+ });
1543
+
1544
+ it('sendManualClosedCaption falls back to the default socket and binding when the practice session is not connected', () => {
1545
+ isPracticeSessionConnected = false;
1546
+
1547
+ voiceaService.sendManualClosedCaption('caption', 123, [456], false);
1548
+
1549
+ assert.calledOnce(defaultSocket.send);
1550
+ assert.notCalled(practiceSocket.send);
1551
+
1552
+ const sent = defaultSocket.send.getCall(0).args[0];
1553
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1554
+ expect(sent).to.have.nested.property(
1555
+ 'data.transcriptPayload.type',
1556
+ 'manual_caption_interim_result'
1557
+ );
1558
+ });
1559
+
1560
+ it('processes relay events from the practice session channel', async () => {
1561
+ const announcementSpy = sinon.spy(voiceaService, 'processAnnouncementMessage');
1562
+
1563
+ voiceaService.listenToEvents();
1564
+
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
+ });
1577
+
1578
+ assert.calledOnce(announcementSpy);
1579
+ assert.equal(voiceaService.captionServiceId, 'svc-practice');
1580
+ });
1581
+ });
1104
1582
  });
1105
1583
  });