Skip to content

Commit be18d2c

Browse files
authored
DataGrid - AI Assistant: Add processing to regenerate requests (#33551)
Co-authored-by: Alyar <>
1 parent 52aeb52 commit be18d2c

11 files changed

Lines changed: 503 additions & 139 deletions

File tree

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

Lines changed: 207 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,113 @@ 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+
controller.sendRequestToAI({
291+
author: { id: 'user', name: 'User' },
292+
text: 'Second request',
293+
timestamp: '2026-04-16T10:00:01.000Z',
294+
} as Message).catch(() => {});
295+
296+
const messages = await getStore(controller).load();
297+
298+
expect(messages).toHaveLength(1);
299+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(1);
301300
});
302301

303-
it('should return false after successful command completion', async () => {
302+
it('should accept new request after previous request completes successfully', async () => {
304303
const controller = createController({
305304
'aiAssistant.aiIntegration': mockAIIntegration,
306305
});
307306

308-
const promise = controller.sendRequestToAI({
307+
const firstPromise = controller.sendRequestToAI({
309308
author: { id: 'user', name: 'User' },
310-
text: 'Generate values',
309+
text: 'First request',
311310
timestamp: '2026-04-16T10:00:00.000Z',
312311
} as Message);
313312

314313
const actions = [{ name: 'sort', args: { column: 'Name' } }];
315314
sendRequestCallbacks.onComplete?.({ actions });
315+
await firstPromise;
316316

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

319-
expect(controller.isProcessing()).toBe(false);
326+
expect(messages).toHaveLength(2);
327+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
320328
});
321329

322-
it('should return false after onError callback', async () => {
330+
it('should accept new request after previous request fails with error', async () => {
323331
const controller = createController({
324332
'aiAssistant.aiIntegration': mockAIIntegration,
325333
});
326334

327-
const promise = controller.sendRequestToAI({
335+
const firstPromise = controller.sendRequestToAI({
328336
author: { id: 'user', name: 'User' },
329-
text: 'Generate values',
337+
text: 'First request',
330338
timestamp: '2026-04-16T10:00:00.000Z',
331339
} as Message);
332-
promise.catch(() => {});
340+
firstPromise.catch(() => {});
333341

334342
sendRequestCallbacks.onError?.(new Error('Network error'));
343+
await expect(firstPromise).rejects.toThrow('Network error');
335344

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

338-
expect(controller.isProcessing()).toBe(false);
354+
expect(messages).toHaveLength(2);
355+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
339356
});
340357

341-
it('should return false after failed command processing', async () => {
358+
it('should accept new request after previous request is aborted', async () => {
342359
const controller = createController({
343360
'aiAssistant.aiIntegration': mockAIIntegration,
344361
});
345362

346-
const promise = controller.sendRequestToAI({
363+
const firstPromise = controller.sendRequestToAI({
347364
author: { id: 'user', name: 'User' },
348-
text: 'Generate values',
365+
text: 'First request',
349366
timestamp: '2026-04-16T10:00:00.000Z',
350367
} as Message);
351-
promise.catch(() => {});
368+
firstPromise.catch(() => {});
352369

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

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

357-
expect(controller.isProcessing()).toBe(false);
382+
expect(messages).toHaveLength(2);
383+
expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2);
358384
});
359385
});
360386

@@ -387,7 +413,7 @@ describe('AIAssistantController', () => {
387413
await expect(promise).rejects.toThrow('Request stopped.');
388414
});
389415

390-
it('should set isProcessing to false when request is aborted', async () => {
416+
it('should call gridCommands.abort when request is aborted', async () => {
391417
const controller = createController({
392418
'aiAssistant.aiIntegration': mockAIIntegration,
393419
});
@@ -399,34 +425,169 @@ describe('AIAssistantController', () => {
399425
} as Message);
400426
promise.catch(() => {});
401427

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

404430
controller.abortRequest();
405431

406432
await expect(promise).rejects.toThrow();
407433

408-
expect(controller.isProcessing()).toBe(false);
434+
expect(gridCommandsInstance.abort).toHaveBeenCalledTimes(1);
409435
});
436+
});
410437

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

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(() => {});
444+
const aiMessage: AIMessage = {
445+
id: 'assistant-123',
446+
author: AI_ASSISTANT_AUTHOR,
447+
text: MessageStatus.Failure,
448+
prompt: 'Generate values',
449+
status: MessageStatus.Failure,
450+
headerText: 'Failed to process request',
451+
errorText: 'Network error',
452+
};
422453

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

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

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

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

0 commit comments

Comments
 (0)