@webex/internal-plugin-ai-assistant 0.0.0-next.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.
- package/.eslintrc.js +6 -0
- package/README.md +157 -0
- package/babel.config.js +3 -0
- package/dist/ai-assistant.js +491 -0
- package/dist/ai-assistant.js.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.js +46 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +3 -0
- package/package.json +53 -0
- package/src/ai-assistant.ts +448 -0
- package/src/config.ts +13 -0
- package/src/constants.ts +38 -0
- package/src/index.ts +8 -0
- package/src/types.ts +46 -0
- package/test/unit/spec/ai-assistant.ts +363 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2015-2022 Cisco Systems, Inc. See LICENSE file.
|
|
3
|
+
*/
|
|
4
|
+
/* eslint-disable no-underscore-dangle */
|
|
5
|
+
import chai from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
7
|
+
import {assert, expect} from '@webex/test-helper-chai';
|
|
8
|
+
import AIAssistant from '@webex/internal-plugin-ai-assistant';
|
|
9
|
+
import MockWebex from '@webex/test-helper-mock-webex';
|
|
10
|
+
import sinon from 'sinon';
|
|
11
|
+
import {set} from 'lodash';
|
|
12
|
+
import uuid from 'uuid';
|
|
13
|
+
import {Timer} from '@webex/common-timers';
|
|
14
|
+
import config from '@webex/internal-plugin-ai-assistant/src/config';
|
|
15
|
+
import { AI_ASSISTANT_ERROR_CODES, AI_ASSISTANT_ERRORS } from '@webex/internal-plugin-ai-assistant/src/constants';
|
|
16
|
+
|
|
17
|
+
const waitForAsync = () =>
|
|
18
|
+
new Promise<void>((resolve) =>
|
|
19
|
+
setImmediate(() => {
|
|
20
|
+
return resolve();
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
chai.use(chaiAsPromised);
|
|
26
|
+
describe('plugin-ai-assistant', () => {
|
|
27
|
+
describe('AIAssistant', () => {
|
|
28
|
+
let webex;
|
|
29
|
+
let uuidStub;
|
|
30
|
+
let mercuryCallbacks;
|
|
31
|
+
let clock;
|
|
32
|
+
let timerSpy;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
webex = MockWebex({
|
|
36
|
+
canAuthorize: false,
|
|
37
|
+
children: {
|
|
38
|
+
aiAssistant: AIAssistant,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Set up default configuration
|
|
43
|
+
webex.config.aiassistant = config;
|
|
44
|
+
|
|
45
|
+
uuidStub = sinon.stub(uuid, 'v4').returns('test-request-id');
|
|
46
|
+
|
|
47
|
+
webex.canAuthorize = true;
|
|
48
|
+
|
|
49
|
+
mercuryCallbacks = {};
|
|
50
|
+
|
|
51
|
+
webex.internal.mercury = {
|
|
52
|
+
connect: sinon.stub().returns(Promise.resolve()),
|
|
53
|
+
disconnect: sinon.stub().returns(Promise.resolve()),
|
|
54
|
+
on: sinon.stub().callsFake((event, callback) => {
|
|
55
|
+
mercuryCallbacks[event] = callback;
|
|
56
|
+
}),
|
|
57
|
+
off: sinon.spy(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
clock = sinon.useFakeTimers();
|
|
61
|
+
|
|
62
|
+
// Stub Timer so we can control it in tests
|
|
63
|
+
timerSpy = sinon.spy(Timer.prototype, 'start');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
uuidStub.restore();
|
|
68
|
+
clock.restore();
|
|
69
|
+
if (timerSpy) {
|
|
70
|
+
timerSpy.restore();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('#register()', () => {
|
|
75
|
+
it('registers correctly', async () => {
|
|
76
|
+
await webex.internal.aiAssistant.register();
|
|
77
|
+
|
|
78
|
+
assert.callCount(webex.internal.mercury.on, 1);
|
|
79
|
+
|
|
80
|
+
const callArgs = webex.internal.mercury.on.getCall(0).args;
|
|
81
|
+
|
|
82
|
+
expect(callArgs[0]).to.equal('event:assistant-api.response');
|
|
83
|
+
expect(callArgs[1]).to.be.a('function');
|
|
84
|
+
|
|
85
|
+
assert.equal(webex.internal.aiAssistant.registered, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects when it cannot authorize', async () => {
|
|
89
|
+
webex.canAuthorize = false;
|
|
90
|
+
|
|
91
|
+
await expect(webex.internal.aiAssistant.register()).to.be.rejectedWith(
|
|
92
|
+
Error,
|
|
93
|
+
'SDK cannot authorize'
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
assert.equal(webex.internal.aiAssistant.registered, false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('resolves immediately when already registered', async () => {
|
|
100
|
+
webex.internal.aiAssistant.registered = true;
|
|
101
|
+
|
|
102
|
+
await webex.internal.aiAssistant.register();
|
|
103
|
+
|
|
104
|
+
assert.callCount(webex.internal.mercury.connect, 0);
|
|
105
|
+
assert.equal(webex.internal.aiAssistant.registered, true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('#unregister()', () => {
|
|
110
|
+
it('unregisters correctly', async () => {
|
|
111
|
+
webex.internal.aiAssistant.registered = true;
|
|
112
|
+
|
|
113
|
+
await webex.internal.aiAssistant.unregister();
|
|
114
|
+
|
|
115
|
+
assert.callCount(webex.internal.mercury.off, 1);
|
|
116
|
+
|
|
117
|
+
const callArgs = webex.internal.mercury.off.getCall(0).args;
|
|
118
|
+
|
|
119
|
+
expect(callArgs[0]).to.equal('event:assistant-api.response');
|
|
120
|
+
|
|
121
|
+
assert.equal(webex.internal.aiAssistant.registered, false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('resolves immediately when not registered', async () => {
|
|
125
|
+
webex.internal.aiAssistant.registered = false;
|
|
126
|
+
|
|
127
|
+
const result = await webex.internal.aiAssistant.unregister();
|
|
128
|
+
|
|
129
|
+
expect(result).to.be.undefined;
|
|
130
|
+
assert.callCount(webex.internal.mercury.disconnect, 0);
|
|
131
|
+
assert.equal(webex.internal.aiAssistant.registered, false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Interface for the data object used in testing events
|
|
136
|
+
interface AssistantEventData {
|
|
137
|
+
clientRequestId: any;
|
|
138
|
+
response: {
|
|
139
|
+
errorMessage?: string;
|
|
140
|
+
errorCode?: string;
|
|
141
|
+
[key: string]: any;
|
|
142
|
+
};
|
|
143
|
+
finished?: boolean;
|
|
144
|
+
[key: string]: any;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Helper function to create data objects for testing events
|
|
148
|
+
const createData = (
|
|
149
|
+
requestId: any,
|
|
150
|
+
finished?: boolean,
|
|
151
|
+
dataPath?: string,
|
|
152
|
+
value?: any,
|
|
153
|
+
encryptionKeyUrl?: string,
|
|
154
|
+
errorMessage?: string,
|
|
155
|
+
errorCode?: string
|
|
156
|
+
): AssistantEventData => {
|
|
157
|
+
const data: AssistantEventData = {
|
|
158
|
+
clientRequestId: requestId,
|
|
159
|
+
response: {}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (finished !== undefined) {
|
|
163
|
+
data.finished = finished;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (value !== undefined && encryptionKeyUrl !== undefined) {
|
|
167
|
+
set(data, dataPath, {
|
|
168
|
+
value,
|
|
169
|
+
encryptionKeyUrl
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (errorMessage !== undefined) {
|
|
174
|
+
data.response.errorMessage = errorMessage;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (errorCode !== undefined) {
|
|
178
|
+
data.response.errorCode = errorCode;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return data;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
describe('#_request', () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
webex.request = sinon.stub().resolves({
|
|
187
|
+
body: {
|
|
188
|
+
sessionId: 'test-session-id'
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Mock encryption functions
|
|
193
|
+
webex.internal.encryption = {
|
|
194
|
+
decryptText: sinon.stub().callsFake(async (keyUrl, value) => {
|
|
195
|
+
return `decrypted-${value}`;
|
|
196
|
+
})
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('makes a request to the assistant API', async () => {
|
|
201
|
+
const requestPromise = webex.internal.aiAssistant._request({
|
|
202
|
+
resource: 'test-resource',
|
|
203
|
+
params: { param1: 'value1' },
|
|
204
|
+
dataPath: 'response.content'
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(webex.request.getCall(0).args[0]).to.deep.equal({
|
|
208
|
+
service: 'assistant-api',
|
|
209
|
+
resource: 'test-resource',
|
|
210
|
+
method: 'POST',
|
|
211
|
+
contentType: 'application/json',
|
|
212
|
+
body: {
|
|
213
|
+
clientRequestId: 'test-request-id',
|
|
214
|
+
param1: 'value1'
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const result = await requestPromise;
|
|
219
|
+
|
|
220
|
+
expect(result).to.deep.equal({
|
|
221
|
+
requestId: 'test-request-id',
|
|
222
|
+
sessionId: 'test-session-id',
|
|
223
|
+
streamEventName: 'aiassistant:stream:test-request-id'
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('decrypts and emits data when receiving event data', async () => {
|
|
228
|
+
const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
|
|
229
|
+
|
|
230
|
+
await webex.internal.aiAssistant._request({
|
|
231
|
+
resource: 'test-resource',
|
|
232
|
+
params: { param1: 'value1' },
|
|
233
|
+
dataPath: 'response.content'
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Simulate mercury event with data
|
|
237
|
+
webex.internal.aiAssistant._handleEvent(
|
|
238
|
+
createData('test-request-id', true, 'response.content', 'test-value', 'test-key-url')
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await waitForAsync();
|
|
242
|
+
|
|
243
|
+
// Verify decryption was called
|
|
244
|
+
expect(webex.internal.encryption.decryptText.calledOnce).to.be.true;
|
|
245
|
+
expect(webex.internal.encryption.decryptText.getCall(0).args).to.deep.equal([
|
|
246
|
+
'test-key-url',
|
|
247
|
+
'test-value'
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
// Verify event was triggered with decrypted data
|
|
251
|
+
expect(triggerSpy.calledTwice).to.be.true; // Called once for streamEvent, once for resultEvent
|
|
252
|
+
expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
253
|
+
expect(triggerSpy.getCall(1).args[1]).to.deep.include({
|
|
254
|
+
message: 'decrypted-test-value',
|
|
255
|
+
requestId: 'test-request-id',
|
|
256
|
+
finished: true,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('concatenates streamed messages for non-finished events', async () => {
|
|
261
|
+
const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
|
|
262
|
+
|
|
263
|
+
await webex.internal.aiAssistant._request({
|
|
264
|
+
resource: 'test-resource',
|
|
265
|
+
params: { param1: 'value1' },
|
|
266
|
+
dataPath: 'response.content'
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Simulate first message chunk
|
|
270
|
+
webex.internal.aiAssistant._handleEvent(
|
|
271
|
+
createData('test-request-id', false, 'response.content', 'first-part-', 'test-key-url')
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await waitForAsync();
|
|
275
|
+
|
|
276
|
+
// Check first chunk
|
|
277
|
+
expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
278
|
+
expect(triggerSpy.getCall(1).args[1]).to.deep.include({
|
|
279
|
+
message: 'decrypted-first-part-',
|
|
280
|
+
finished: false
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Simulate second message chunk
|
|
284
|
+
webex.internal.aiAssistant._handleEvent(
|
|
285
|
+
createData('test-request-id', false, 'response.content', 'second-part', 'test-key-url')
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
await waitForAsync();
|
|
289
|
+
|
|
290
|
+
// Check second chunk - should include first and second part concatenated
|
|
291
|
+
expect(triggerSpy.getCall(3).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
292
|
+
expect(triggerSpy.getCall(3).args[1]).to.deep.include({
|
|
293
|
+
message: 'decrypted-first-part-decrypted-second-part',
|
|
294
|
+
finished: false
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Simulate final message
|
|
298
|
+
webex.internal.aiAssistant._handleEvent(
|
|
299
|
+
createData('test-request-id', true, 'response.content', 'final-part', 'test-key-url')
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await waitForAsync();
|
|
303
|
+
|
|
304
|
+
// Check all trigger calls - first two should have concatenated message
|
|
305
|
+
expect(triggerSpy.callCount).to.equal(6); // Three event pairs for result and stream
|
|
306
|
+
|
|
307
|
+
// Check final message - stops concatenation, only returns the final version
|
|
308
|
+
expect(triggerSpy.getCall(5).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
309
|
+
expect(triggerSpy.getCall(5).args[1]).to.deep.include({
|
|
310
|
+
message: 'decrypted-final-part',
|
|
311
|
+
finished: true
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('rejects with AIAssistantTimeoutError when server does not respond in time', async () => {
|
|
316
|
+
const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
|
|
317
|
+
await webex.internal.aiAssistant._request({
|
|
318
|
+
resource: 'test-resource',
|
|
319
|
+
params: { param1: 'value1' },
|
|
320
|
+
dataPath: 'response.content'
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Advance the clock past the timeout
|
|
324
|
+
await clock.tickAsync(30001); // Default timeout + 1ms
|
|
325
|
+
|
|
326
|
+
await waitForAsync();
|
|
327
|
+
|
|
328
|
+
expect(triggerSpy.getCall(0).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
329
|
+
expect(triggerSpy.getCall(0).args[1]).to.deep.include({
|
|
330
|
+
requestId: 'test-request-id',
|
|
331
|
+
finished: true,
|
|
332
|
+
errorMessage: AI_ASSISTANT_ERRORS.AI_ASSISTANT_TIMEOUT,
|
|
333
|
+
errorCode: AI_ASSISTANT_ERROR_CODES.AI_ASSISTANT_TIMEOUT
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('includes error information when server returns an error', async () => {
|
|
338
|
+
const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
|
|
339
|
+
|
|
340
|
+
await webex.internal.aiAssistant._request({
|
|
341
|
+
resource: 'test-resource',
|
|
342
|
+
params: { param1: 'value1' },
|
|
343
|
+
dataPath: 'response.content'
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Simulate mercury event with error
|
|
347
|
+
webex.internal.aiAssistant._handleEvent(
|
|
348
|
+
createData('test-request-id', true, null, null, null, 'Error message', 'ERROR_CODE')
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
expect(triggerSpy.calledTwice).to.be.true;
|
|
352
|
+
expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
|
|
353
|
+
expect(triggerSpy.getCall(1).args[1]).to.deep.include({
|
|
354
|
+
message: '',
|
|
355
|
+
requestId: 'test-request-id',
|
|
356
|
+
finished: true,
|
|
357
|
+
errorMessage: 'Error message',
|
|
358
|
+
errorCode: 'ERROR_CODE'
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|