Skip to content

Commit 7b42cea

Browse files
author
Alyar
committed
DataGrid: handle AI Assistant regenerate requests
1 parent 81d67fd commit 7b42cea

11 files changed

Lines changed: 493 additions & 122 deletions

File tree

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts

Lines changed: 208 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
MessageStatus,
1818
} from '../const';
1919
import { GridCommands } from '../grid_commands';
20-
import type { CommandResult } from '../types';
20+
import type { AIMessage, CommandResult } from '../types';
2121

2222
jest.mock('../grid_commands');
2323

@@ -274,87 +274,114 @@ describe('AIAssistantController', () => {
274274

275275
await expect(promise).rejects.toThrow('Default error message');
276276
});
277-
});
278277

279-
describe('isProcessing', () => {
280-
it('should return false by default', () => {
281-
const controller = createController({
282-
'aiAssistant.aiIntegration': mockAIIntegration,
283-
});
284-
285-
expect(controller.isProcessing()).toBe(false);
286-
});
287-
288-
it('should return true after sendRequestToAI is called', () => {
278+
it('should ignore second request while first request is still processing', async () => {
289279
const controller = createController({
290280
'aiAssistant.aiIntegration': mockAIIntegration,
291281
});
292282

293283
// eslint-disable-next-line @typescript-eslint/no-floating-promises
294284
controller.sendRequestToAI({
295285
author: { id: 'user', name: 'User' },
296-
text: 'Generate values',
286+
text: 'First request',
297287
timestamp: '2026-04-16T10:00:00.000Z',
298288
} as Message);
299289

300-
expect(controller.isProcessing()).toBe(true);
290+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
291+
controller.sendRequestToAI({
292+
author: { id: 'user', name: 'User' },
293+
text: 'Second request',
294+
timestamp: '2026-04-16T10:00:01.000Z',
295+
} as Message);
296+
297+
const messages = await getStore(controller).load();
298+
299+
expect(messages).toHaveLength(1);
300+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(1);
301301
});
302302

303-
it('should return false after successful command completion', async () => {
303+
it('should accept new request after previous request completes successfully', async () => {
304304
const controller = createController({
305305
'aiAssistant.aiIntegration': mockAIIntegration,
306306
});
307307

308-
const promise = controller.sendRequestToAI({
308+
const firstPromise = controller.sendRequestToAI({
309309
author: { id: 'user', name: 'User' },
310-
text: 'Generate values',
310+
text: 'First request',
311311
timestamp: '2026-04-16T10:00:00.000Z',
312312
} as Message);
313313

314314
const actions = [{ name: 'sort', args: { column: 'Name' } }];
315315
sendRequestCallbacks.onComplete?.({ actions });
316+
await firstPromise;
316317

317-
await promise;
318+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
319+
controller.sendRequestToAI({
320+
author: { id: 'user', name: 'User' },
321+
text: 'Second request',
322+
timestamp: '2026-04-16T10:00:01.000Z',
323+
} as Message);
324+
325+
const messages = await getStore(controller).load();
318326

319-
expect(controller.isProcessing()).toBe(false);
327+
expect(messages).toHaveLength(2);
328+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
320329
});
321330

322-
it('should return false after onError callback', async () => {
331+
it('should accept new request after previous request fails with error', async () => {
323332
const controller = createController({
324333
'aiAssistant.aiIntegration': mockAIIntegration,
325334
});
326335

327-
const promise = controller.sendRequestToAI({
336+
const firstPromise = controller.sendRequestToAI({
328337
author: { id: 'user', name: 'User' },
329-
text: 'Generate values',
338+
text: 'First request',
330339
timestamp: '2026-04-16T10:00:00.000Z',
331340
} as Message);
332-
promise.catch(() => {});
341+
firstPromise.catch(() => {});
333342

334343
sendRequestCallbacks.onError?.(new Error('Network error'));
344+
await expect(firstPromise).rejects.toThrow('Network error');
335345

336-
await expect(promise).rejects.toThrow('Network error');
346+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
347+
controller.sendRequestToAI({
348+
author: { id: 'user', name: 'User' },
349+
text: 'Second request',
350+
timestamp: '2026-04-16T10:00:01.000Z',
351+
} as Message);
352+
353+
const messages = await getStore(controller).load();
337354

338-
expect(controller.isProcessing()).toBe(false);
355+
expect(messages).toHaveLength(2);
356+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
339357
});
340358

341-
it('should return false after failed command processing', async () => {
359+
it('should accept new request after previous request is aborted', async () => {
342360
const controller = createController({
343361
'aiAssistant.aiIntegration': mockAIIntegration,
344362
});
345363

346-
const promise = controller.sendRequestToAI({
364+
const firstPromise = controller.sendRequestToAI({
347365
author: { id: 'user', name: 'User' },
348-
text: 'Generate values',
366+
text: 'First request',
349367
timestamp: '2026-04-16T10:00:00.000Z',
350368
} as Message);
351-
promise.catch(() => {});
369+
firstPromise.catch(() => {});
352370

353-
sendRequestCallbacks.onComplete?.({} as ExecuteGridAssistantCommandResult);
371+
controller.abortRequest();
372+
await expect(firstPromise).rejects.toThrow();
354373

355-
await expect(promise).rejects.toThrow('Default error message');
374+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
375+
controller.sendRequestToAI({
376+
author: { id: 'user', name: 'User' },
377+
text: 'Second request',
378+
timestamp: '2026-04-16T10:00:01.000Z',
379+
} as Message);
380+
381+
const messages = await getStore(controller).load();
356382

357-
expect(controller.isProcessing()).toBe(false);
383+
expect(messages).toHaveLength(2);
384+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
358385
});
359386
});
360387

@@ -387,7 +414,7 @@ describe('AIAssistantController', () => {
387414
await expect(promise).rejects.toThrow('Request stopped.');
388415
});
389416

390-
it('should set isProcessing to false when request is aborted', async () => {
417+
it('should call gridCommands.abort when request is aborted', async () => {
391418
const controller = createController({
392419
'aiAssistant.aiIntegration': mockAIIntegration,
393420
});
@@ -399,34 +426,169 @@ describe('AIAssistantController', () => {
399426
} as Message);
400427
promise.catch(() => {});
401428

402-
expect(controller.isProcessing()).toBe(true);
429+
const gridCommandsInstance = MockedGridCommands.mock.results[0].value as { abort: jest.Mock };
403430

404431
controller.abortRequest();
405432

406433
await expect(promise).rejects.toThrow();
407434

408-
expect(controller.isProcessing()).toBe(false);
435+
expect(gridCommandsInstance.abort).toHaveBeenCalledTimes(1);
409436
});
437+
});
410438

411-
it('should call gridCommands.abort when request is aborted', async () => {
439+
describe('sendRequestToAI with AIMessage (regenerate)', () => {
440+
it('should reset message status to pending when AIMessage is passed', async () => {
412441
const controller = createController({
413442
'aiAssistant.aiIntegration': mockAIIntegration,
414443
});
415444

416-
const promise = controller.sendRequestToAI({
417-
author: { id: 'user', name: 'User' },
418-
text: 'Generate values',
419-
timestamp: '2026-04-16T10:00:00.000Z',
420-
} as Message);
421-
promise.catch(() => {});
445+
const aiMessage: AIMessage = {
446+
id: 'assistant-123',
447+
author: AI_ASSISTANT_AUTHOR,
448+
text: MessageStatus.Failure,
449+
prompt: 'Generate values',
450+
status: MessageStatus.Failure,
451+
headerText: 'Failed to process request',
452+
errorText: 'Network error',
453+
};
422454

423-
const gridCommandsInstance = MockedGridCommands.mock.results[0].value as { abort: jest.Mock };
455+
const store = getStore(controller);
456+
await store.insert(aiMessage);
424457

425-
controller.abortRequest();
458+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
459+
controller.sendRequestToAI(aiMessage);
426460

427-
await expect(promise).rejects.toThrow();
461+
const messages = await store.load();
428462

429-
expect(gridCommandsInstance.abort).toHaveBeenCalledTimes(1);
463+
expect(messages).toHaveLength(1);
464+
expect(messages).toEqual([
465+
expect.objectContaining({
466+
id: 'assistant-123',
467+
status: MessageStatus.Pending,
468+
headerText: 'Request in progress',
469+
text: MessageStatus.Pending,
470+
}),
471+
]);
472+
});
473+
474+
it('should not create new message when AIMessage is passed', async () => {
475+
const controller = createController({
476+
'aiAssistant.aiIntegration': mockAIIntegration,
477+
});
478+
479+
const aiMessage: AIMessage = {
480+
id: 'assistant-123',
481+
author: AI_ASSISTANT_AUTHOR,
482+
text: MessageStatus.Failure,
483+
prompt: 'Generate values',
484+
status: MessageStatus.Failure,
485+
headerText: 'Failed to process request',
486+
errorText: 'Network error',
487+
};
488+
489+
const store = getStore(controller);
490+
await store.insert(aiMessage);
491+
492+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
493+
controller.sendRequestToAI(aiMessage);
494+
495+
const messages = await store.load();
496+
497+
expect(messages).toHaveLength(1);
498+
});
499+
500+
it('should send request with original prompt from AIMessage', () => {
501+
const controller = createController({
502+
'aiAssistant.aiIntegration': mockAIIntegration,
503+
});
504+
505+
const aiMessage: AIMessage = {
506+
id: 'assistant-123',
507+
author: AI_ASSISTANT_AUTHOR,
508+
text: MessageStatus.Failure,
509+
prompt: 'Sort by Name column',
510+
status: MessageStatus.Failure,
511+
headerText: 'Failed to process request',
512+
errorText: 'Network error',
513+
};
514+
515+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
516+
controller.sendRequestToAI(aiMessage);
517+
518+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledWith(
519+
expect.objectContaining({
520+
text: 'Sort by Name column',
521+
}),
522+
expect.any(Object),
523+
);
524+
});
525+
526+
it('should clear errorText and commands when regenerating', async () => {
527+
const controller = createController({
528+
'aiAssistant.aiIntegration': mockAIIntegration,
529+
});
530+
531+
const aiMessage: AIMessage = {
532+
id: 'assistant-123',
533+
author: AI_ASSISTANT_AUTHOR,
534+
text: MessageStatus.Failure,
535+
prompt: 'Generate values',
536+
status: MessageStatus.Failure,
537+
headerText: 'Failed to process request',
538+
errorText: 'Network error',
539+
commands: [{ status: 'failure', message: 'sort failed' }],
540+
};
541+
542+
const store = getStore(controller);
543+
await store.insert(aiMessage);
544+
545+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
546+
controller.sendRequestToAI(aiMessage);
547+
548+
const messages = await store.load();
549+
550+
expect(messages).toEqual([
551+
expect.objectContaining({
552+
errorText: undefined,
553+
commands: undefined,
554+
}),
555+
]);
556+
});
557+
558+
it('should complete regenerated message as success when command succeed', async () => {
559+
const controller = createController({
560+
'aiAssistant.aiIntegration': mockAIIntegration,
561+
});
562+
563+
const aiMessage: AIMessage = {
564+
id: 'assistant-123',
565+
author: AI_ASSISTANT_AUTHOR,
566+
text: MessageStatus.Failure,
567+
prompt: 'Generate values',
568+
status: MessageStatus.Failure,
569+
headerText: 'Failed to process request',
570+
errorText: 'Network error',
571+
};
572+
573+
const store = getStore(controller);
574+
await store.insert(aiMessage);
575+
576+
const promise = controller.sendRequestToAI(aiMessage);
577+
578+
const actions = [{ name: 'sort', args: { column: 'Name' } }];
579+
sendRequestCallbacks.onComplete?.({ actions });
580+
581+
await promise;
582+
583+
const messages = await store.load();
584+
585+
expect(messages).toEqual([
586+
expect.objectContaining({
587+
id: 'assistant-123',
588+
status: MessageStatus.Success,
589+
commands: [{ status: 'success', message: 'sort' }],
590+
}),
591+
]);
430592
});
431593
});
432594
});

0 commit comments

Comments
 (0)