diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts deleted file mode 100644 index ff30a770b8d8..000000000000 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; -import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration'; -import type { ArrayStore } from '@js/common/data'; -import type { Message } from '@js/ui/chat'; -import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands'; -import { - AI_ASSISTANT_AUTHOR, - AI_ASSISTANT_AUTHOR_ID, - MessageStatus, -} from '@ts/grids/grid_core/ai_assistant/const'; -import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; -import type { - AIAssistantRequestCallbacks, - AIMessage, - CommandResult, -} from '@ts/grids/grid_core/ai_assistant/types'; -import type { InternalGrid } from '@ts/grids/grid_core/m_types'; - -import { DataGridAIAssistantController } from '../ai_assistant_controller'; -import { DataGridAIAssistantIntegrationController } from '../ai_assistant_integration_controller'; -import { dataGridCommands } from '../commands/index'; - -jest.mock('@ts/grids/grid_core/ai_assistant/grid_commands'); -jest.mock('../ai_assistant_integration_controller'); - -const MockedGridCommands = GridCommands as jest.MockedClass; -const MockedDataGridAIAssistantIntegrationController = DataGridAIAssistantIntegrationController as - jest.MockedClass; - -let sendRequestCallbacks: AIAssistantRequestCallbacks = {}; - -const createController = ( - options: Record = {}, -): DataGridAIAssistantController => { - const mockComponent = { - _optionCache: {}, - _controllers: {}, - option: jest.fn((name?: string) => { - if (name === undefined) { - return options; - } - - return options[name]; - }), - _createActionByOption: jest.fn(() => jest.fn()), - }; - - const controller = new DataGridAIAssistantController( - mockComponent as unknown as InternalGrid, - ); - controller.init(); - - return controller; -}; - -const getStore = ( - controller: DataGridAIAssistantController, -): ArrayStore => { - const dataSource = controller.getMessageDataSource() as { - store: ArrayStore; - }; - return dataSource.store; -}; - -describe('DataGridAIAssistantController', () => { - beforeEach(() => { - jest.clearAllMocks(); - - (MockedGridCommands.mockImplementation as jest.Mock).call( - MockedGridCommands, - () => ({ - validate: jest.fn().mockReturnValue(true), - executeCommands: jest.fn<() => Promise>() - .mockResolvedValue([{ status: 'success', message: 'sort' }]), - abort: jest.fn(), - buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), - isExecuting: jest.fn().mockReturnValue(false), - }), - ); - - (MockedDataGridAIAssistantIntegrationController - .mockImplementation as jest.Mock).call( - MockedDataGridAIAssistantIntegrationController, - () => ({ - init: jest.fn(), - dispose: jest.fn(), - sendRequest: jest.fn(( - _text: string, - _responseSchema: unknown, - callbacks?: AIAssistantRequestCallbacks, - ) => { - sendRequestCallbacks = callbacks ?? {}; - }), - abortRequest: jest.fn(() => { - sendRequestCallbacks.onAbort?.(); - }), - isRequestAwaitingCompletion: jest.fn().mockReturnValue(false), - }), - ); - }); - - describe('getGridCommandList', () => { - it('should include all core commands', () => { - createController(); - - const constructorCall = MockedGridCommands.mock.calls[0]; - const commandList = constructorCall[1]; - const commandNames = commandList.map((c) => c.name); - - for (const coreCommand of coreCommands) { - expect(commandNames).toContain(coreCommand.name); - } - }); - - it('should include all data grid specific commands', () => { - createController(); - - const constructorCall = MockedGridCommands.mock.calls[0]; - const commandList = constructorCall[1]; - const commandNames = commandList.map((c) => c.name); - - expect(commandNames).toContain('grouping'); - expect(commandNames).toContain('clearGrouping'); - expect(commandNames).toContain('summary'); - expect(commandNames).toContain('clearSummary'); - }); - - it('should extend core commands with data grid commands', () => { - createController(); - - const constructorCall = MockedGridCommands.mock.calls[0]; - const commandList = constructorCall[1]; - - expect(commandList).toHaveLength( - coreCommands.length + dataGridCommands.length, - ); - }); - - it('should place core commands before data grid commands', () => { - createController(); - - const constructorCall = MockedGridCommands.mock.calls[0]; - const commandList = constructorCall[1]; - const commandNames = commandList.map((c) => c.name); - - const firstDataGridCommandIndex = commandNames.indexOf('grouping'); - const lastCoreCommandIndex = commandNames.indexOf( - coreCommands[coreCommands.length - 1].name, - ); - - expect(firstDataGridCommandIndex).toBeGreaterThan(lastCoreCommandIndex); - }); - }); - - describe('getAiAssistantIntegrationController', () => { - it('should create DataGridAIAssistantIntegrationController', () => { - createController(); - - expect(MockedDataGridAIAssistantIntegrationController).toHaveBeenCalledTimes(1); - }); - }); - - describe('inherited behavior', () => { - it('should return dataSource with store', () => { - const controller = createController(); - const dataSource = controller.getMessageDataSource() as { - store: ArrayStore; - }; - - expect(dataSource.store).toBeDefined(); - }); - - it('should create pending message in store', async () => { - const controller = createController(); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'Group by category', - timestamp: '2026-04-16T10:00:00.000Z', - } as Message); - - const messages = await getStore(controller).load(); - - expect(messages).toEqual([ - expect.objectContaining({ - id: expect.stringContaining(AI_ASSISTANT_AUTHOR_ID), - author: AI_ASSISTANT_AUTHOR, - status: MessageStatus.Pending, - }), - ]); - }); - - it('should complete message as success when command succeed', async () => { - const controller = createController(); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'Group by category', - timestamp: '2026-04-16T10:00:00.000Z', - } as Message); - - const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }]; - sendRequestCallbacks.onComplete?.({ actions }); - await Promise.resolve(); - - const messages = await getStore(controller).load(); - - expect(messages).toEqual([ - expect.objectContaining({ - status: MessageStatus.Success, - commands: [{ status: 'success', message: 'sort' }], - }), - ]); - }); - - it('should fail message when onError callback is called', async () => { - const controller = createController(); - - const promise = controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'Group by category', - timestamp: '2026-04-16T10:00:00.000Z', - } as Message); - promise.catch(() => {}); - - sendRequestCallbacks.onError?.(new Error('Network error')); - - const messages = await getStore(controller).load(); - - expect(messages).toEqual([ - expect.objectContaining({ - status: MessageStatus.Failure, - errorText: 'Network error', - }), - ]); - - await expect(promise).rejects.toThrow('Network error'); - }); - - it('should abort request and fail message', async () => { - const controller = createController(); - - const promise = controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'Group by category', - timestamp: '2026-04-16T10:00:00.000Z', - } as Message); - promise.catch(() => {}); - - controller.abortRequest(); - - const messages = await getStore(controller).load(); - - expect(messages).toEqual([ - expect.objectContaining({ - status: MessageStatus.Failure, - }), - ]); - - await expect(promise).rejects.toThrow(); - }); - - it('should call integration controller dispose on dispose', () => { - const controller = createController(); - - const integrationInstance = MockedDataGridAIAssistantIntegrationController - .mock.results[0].value as { dispose: jest.Mock }; - - controller.dispose(); - - expect(integrationInstance.dispose).toHaveBeenCalledTimes(1); - }); - - it('should reject second request while first is processing', async () => { - const controller = createController(); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'First request', - timestamp: '2026-04-16T10:00:00.000Z', - } as Message); - - const secondPromise = controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text: 'Second request', - timestamp: '2026-04-16T10:00:01.000Z', - } as Message); - secondPromise.catch(() => {}); - - await expect(secondPromise).rejects.toBeUndefined(); - }); - - it('should support regenerating failed AIMessage', async () => { - const controller = createController(); - - const aiMessage: AIMessage = { - id: 'assistant-123', - author: AI_ASSISTANT_AUTHOR, - text: MessageStatus.Failure, - prompt: 'Group by category', - status: MessageStatus.Failure, - headerText: 'Failed to process request', - errorText: 'Network error', - }; - - const store = getStore(controller); - await store.insert(aiMessage); - - const promise = controller.sendRequestToAI(aiMessage); - - const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }]; - sendRequestCallbacks.onComplete?.({ actions }); - - await promise; - - const messages = await store.load(); - - expect(messages).toEqual([ - expect.objectContaining({ - id: 'assistant-123', - status: MessageStatus.Success, - }), - ]); - }); - }); -}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts index e10bb06864e3..7e371b76287b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, } from '@jest/globals'; +import type { ExecuteGridAssistantAction } from '@js/common/ai-integration'; import { logger } from '@ts/core/utils/m_console'; import { z } from 'zod'; @@ -11,21 +12,12 @@ import type { CommandResult, CustomizeResponseText, GridCommand, + JsonSchema, + ResponseSchemaBranch, } from '../types'; -interface Branch { - type: string; - description: string; - required: string[]; - additionalProperties: boolean; - properties: { - name: { type: string; enum: string[] }; - args: Record; - }; -} - interface SchemaShape { - $schema: string; + $schema?: string; type: string; required: string[]; additionalProperties: boolean; @@ -33,7 +25,7 @@ interface SchemaShape { actions: { type: string; description: string; - items: { anyOf: Branch[] }; + items: { anyOf: ResponseSchemaBranch['branch'][] }; }; }; } @@ -50,7 +42,7 @@ const createMockCommand = ( execute: ( _component: InternalGrid, { success }: CommandCallbacks, - ) => async (): Promise => success(), + ) => (): Promise => Promise.resolve(success()), ...overrides, }); @@ -68,7 +60,7 @@ describe('GridCommands', () => { ( _comp: InternalGrid, { success }: CommandCallbacks, - ) => async (): Promise => success('done'), + ) => (): Promise => Promise.resolve(success('done')), ); const command = createMockCommand('test', { execute: executeSpy }); const gridCommands = new GridCommands(component, [command]); @@ -93,7 +85,7 @@ describe('GridCommands', () => { [commandA, commandB], ); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const commandNames = schema.properties.actions.items.anyOf.map( (branch) => branch.properties.name.enum[0], ); @@ -118,7 +110,7 @@ describe('GridCommands', () => { it('should return CommandResult with status success and default message when called without argument', async () => { const component = createMockComponent(); const command = createMockCommand('test', { - execute: (_comp, { success }) => async () => success(), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success()), }); const gridCommands = new GridCommands(component, [command]); @@ -133,7 +125,7 @@ describe('GridCommands', () => { it('should return CommandResult with status success and custom message', async () => { const component = createMockComponent(); const command = createMockCommand('test', { - execute: (_comp, { success }) => async () => success('Custom msg'), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success('Custom msg')), }); const gridCommands = new GridCommands(component, [command]); @@ -152,7 +144,7 @@ describe('GridCommands', () => { it('should return CommandResult with status failure and default message when called without argument', async () => { const component = createMockComponent(); const command = createMockCommand('test', { - execute: (_comp, { failure }) => async () => failure(), + execute: (_comp, { failure }) => (): Promise => Promise.resolve(failure()), }); const gridCommands = new GridCommands(component, [command]); @@ -167,7 +159,7 @@ describe('GridCommands', () => { it('should return CommandResult with status failure and custom message', async () => { const component = createMockComponent(); const command = createMockCommand('test', { - execute: (_comp, { failure }) => async () => failure('Custom msg'), + execute: (_comp, { failure }) => (): Promise => Promise.resolve(failure('Custom msg')), }); const gridCommands = new GridCommands(component, [command]); @@ -183,11 +175,11 @@ describe('GridCommands', () => { }); describe('buildResponseSchema', () => { - it('should return valid JSON Schema draft-07 object', () => { + it('should return valid JSON Schema object without $schema field', () => { const gridCommands = new GridCommands(createMockComponent(), []); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; - expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#'); + expect(schema.$schema).toBeUndefined(); expect(schema.type).toBe('object'); expect(schema.required).toEqual(['actions']); expect(schema.additionalProperties).toBe(false); @@ -201,7 +193,7 @@ describe('GridCommands', () => { [commandA, commandB], ); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const { anyOf } = schema.properties.actions.items; expect(anyOf).toHaveLength(2); @@ -213,7 +205,7 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const branch = schema.properties.actions.items.anyOf[0]; expect(branch.description).toBe('Apply sorting to one or more columns'); @@ -223,7 +215,7 @@ describe('GridCommands', () => { const command = createMockCommand('sorting'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const branch = schema.properties.actions.items.anyOf[0]; expect(branch.properties.name).toEqual({ @@ -241,8 +233,8 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; - const { args } = schema.properties.actions.items.anyOf[0].properties; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; + const args = schema.properties.actions.items.anyOf[0].properties.args as JsonSchema; expect(args.type).toBe('object'); expect(args.additionalProperties).toBe(false); @@ -254,7 +246,7 @@ describe('GridCommands', () => { const command = createMockCommand('test'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; // Root level expect(schema.additionalProperties).toBe(false); @@ -262,7 +254,7 @@ describe('GridCommands', () => { const branch = schema.properties.actions.items.anyOf[0]; expect(branch.additionalProperties).toBe(false); // Args level - expect(branch.properties.args.additionalProperties).toBe(false); + expect((branch.properties.args as JsonSchema).additionalProperties).toBe(false); }); it('should not have anyOf at root schema level', () => { @@ -279,7 +271,7 @@ describe('GridCommands', () => { it('should return empty anyOf with no commands registered', () => { const gridCommands = new GridCommands(createMockComponent(), []); - const schema = gridCommands.buildResponseSchema() as SchemaShape; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const { anyOf } = schema.properties.actions.items; expect(anyOf).toEqual([]); @@ -289,8 +281,8 @@ describe('GridCommands', () => { const command = createMockCommand('clearSorting'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; - const { args } = schema.properties.actions.items.anyOf[0].properties; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; + const args = schema.properties.actions.items.anyOf[0].properties.args as JsonSchema; expect(args.type).toBe('object'); expect(args.additionalProperties).toBe(false); @@ -305,8 +297,8 @@ describe('GridCommands', () => { createMockCommand('commandB'), ]); - const schema1 = gc1.buildResponseSchema() as SchemaShape; - const schema2 = gc2.buildResponseSchema() as SchemaShape; + const schema1 = gc1.buildResponseSchema() as unknown as SchemaShape; + const schema2 = gc2.buildResponseSchema() as unknown as SchemaShape; const names1 = schema1.properties.actions.items.anyOf.map( (b) => b.properties.name.enum[0], @@ -328,10 +320,106 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const schema = gridCommands.buildResponseSchema() as SchemaShape; - const { args } = schema.properties.actions.items.anyOf[0].properties; + const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; + const args = schema.properties.actions.items.anyOf[0].properties.args as JsonSchema; + + // openai target makes all fields required (optional becomes nullable) + expect(args.required).toEqual(['field1', 'field2']); + }); + + it('should hoist $defs to root level for commands with recursive schemas (z.lazy)', () => { + const recursiveSchema: z.ZodType<{ child: unknown } | string> = z.lazy(() => z.union([ + z.string(), + z.object({ child: recursiveSchema }).strict(), + ])); + + const command = createMockCommand('recursive', { + schema: z.object({ expr: recursiveSchema }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as Record; + + // $defs should exist at root level + expect(schema.$defs).toBeDefined(); + expect(typeof schema.$defs).toBe('object'); + + // No $defs should remain nested inside the branch args + const props = schema.properties as Record; + const actions = props.actions as Record; + const items = actions.items as Record; + const branches = items.anyOf as Record[]; + const branchProps = branches[0].properties as Record; + const args = branchProps.args as Record; + expect(args.$defs).toBeUndefined(); + + // All $ref values in the schema should resolve to keys in root $defs + const defs = schema.$defs as Record; + const allRefs: string[] = []; + const collectRefs = (obj: unknown): void => { + if (!obj || typeof obj !== 'object') return; + if (Array.isArray(obj)) { obj.forEach(collectRefs); return; } + const record = obj as Record; + if (typeof record.$ref === 'string') allRefs.push(record.$ref); + Object.values(record).forEach(collectRefs); + }; + collectRefs(schema); + + for (const ref of allRefs) { + const match = /^#\/\$defs\/(.+)$/.exec(ref); + expect(match).not.toBeNull(); + if (match) { + expect(defs[match[1]]).toBeDefined(); + } + } + }); + + it('should not add $defs to root when no command uses $ref', () => { + const command = createMockCommand('simple', { + schema: z.object({ value: z.string() }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as Record; + + expect(schema.$defs).toBeUndefined(); + }); + + it('should rewrite all inline $ref to #/$defs/ for recursive schemas', () => { + const filterOps = z.enum(['=', '<>', 'contains']); + const basicExpr = z.object({ + type: z.literal('basic'), + field: z.string(), + op: filterOps, + value: z.union([z.string(), z.number(), z.null()]), + }).strict(); + const exprSchema: z.ZodType = z.lazy(() => z.union([ + basicExpr, + z.object({ + type: z.literal('combined'), + left: exprSchema, + combiner: z.enum(['and', 'or']), + right: exprSchema, + }).strict(), + ])); + const filterCommand = createMockCommand('filterValue', { + schema: z.object({ + expression: exprSchema.nullable(), + }).strict(), + }); + const gridCommands = new GridCommands( + createMockComponent(), + [filterCommand], + ); + + const schema = gridCommands.buildResponseSchema(); + const json = JSON.stringify(schema); + + // No $ref should contain inline paths + expect(json).not.toMatch(/"\$ref"\s*:\s*"#\/properties\//); - expect(args.required).toEqual(['field1']); + // $defs should be at root + expect(schema.$defs).toBeDefined(); }); }); @@ -392,11 +480,11 @@ describe('GridCommands', () => { ]); expect(gridCommands.validate( - [{ args: {} } as any], + [{ args: {} }] as unknown as ExecuteGridAssistantAction[], )).toBe(false); expect(gridCommands.validate( - [{ name: 'test' } as any], + [{ name: 'test' }] as unknown as ExecuteGridAssistantAction[], )).toBe(false); }); @@ -498,21 +586,21 @@ describe('GridCommands', () => { const executionOrder: string[] = []; const commandA = createMockCommand('a', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { executionOrder.push('a'); - return success(); + return Promise.resolve(success()); }, }); const commandB = createMockCommand('b', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { executionOrder.push('b'); - return success(); + return Promise.resolve(success()); }, }); const commandC = createMockCommand('c', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { executionOrder.push('c'); - return success(); + return Promise.resolve(success()); }, }); const gridCommands = new GridCommands( @@ -533,16 +621,17 @@ describe('GridCommands', () => { const executionOrder: string[] = []; const commandA = createMockCommand('a', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => async (): Promise => { + // eslint-disable-next-line no-restricted-globals await new Promise((resolve) => { setTimeout(resolve, 50); }); executionOrder.push('a'); return success(); }, }); const commandB = createMockCommand('b', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { executionOrder.push('b'); - return success(); + return Promise.resolve(success()); }, }); const gridCommands = new GridCommands( @@ -578,7 +667,7 @@ describe('GridCommands', () => { it('should produce failure result when executor throws synchronously', async () => { const command = createMockCommand('throwing', { - execute: () => () => { + execute: () => (): Promise => { throw new Error('sync error'); }, }); @@ -593,9 +682,7 @@ describe('GridCommands', () => { it('should produce failure result when async executor rejects', async () => { const command = createMockCommand('rejecting', { - execute: () => async () => { - throw new Error('async error'); - }, + execute: () => (): Promise => Promise.reject(new Error('async error')), }); const gridCommands = new GridCommands(createMockComponent(), [command]); @@ -609,9 +696,7 @@ describe('GridCommands', () => { it('should log "Error executing command" when executor throws', async () => { const error = new Error('something went wrong'); const command = createMockCommand('failing', { - execute: () => async () => { - throw error; - }, + execute: () => (): Promise => Promise.reject(error), }); const gridCommands = new GridCommands(createMockComponent(), [command]); const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); @@ -655,7 +740,7 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const blockingCommand = createMockCommand('blocking', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => async (): Promise => { await expect( gridCommands.executeCommands([]), ).rejects.toThrow('executeCommands is already in progress'); @@ -687,10 +772,10 @@ describe('GridCommands', () => { it('should record success and failure statuses correctly', async () => { const successCommand = createMockCommand('ok', { - execute: (_comp, { success }) => async () => success(), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success()), }); const failCommand = createMockCommand('fail', { - execute: (_comp, { failure }) => async () => failure(), + execute: (_comp, { failure }) => (): Promise => Promise.resolve(failure()), }); const gridCommands = new GridCommands( createMockComponent(), @@ -714,9 +799,9 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const first = createMockCommand('first', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); - return success('first done'); + return Promise.resolve(success('first done')); }, }); const second = createMockCommand('second'); @@ -741,11 +826,11 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const first = createMockCommand('first', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); gridCommands.abort(); gridCommands.abort(); - return success(); + return Promise.resolve(success()); }, }); const second = createMockCommand('second'); @@ -768,9 +853,9 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const first = createMockCommand('first', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); - return success(); + return Promise.resolve(success()); }, }); const second = createMockCommand('second'); @@ -799,9 +884,9 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const abortSimulation = createMockCommand('abort', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); - return success(); + return Promise.resolve(success()); }, }); const normal = createMockCommand('normal'); @@ -828,7 +913,7 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const blockingCommand = createMockCommand('blocking', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => async (): Promise => { gridCommands.abort(); try { await gridCommands.executeCommands([]); @@ -877,9 +962,9 @@ describe('GridCommands', () => { const executeSpy = jest.fn(( _comp: InternalGrid, { success }: CommandCallbacks, - ) => async (): Promise => { + ) => (): Promise => { capturedIsExecuting = gridCommands.isExecuting(); - return success(); + return Promise.resolve(success()); }); const spyCommand = createMockCommand('spy', { execute: executeSpy, @@ -899,9 +984,9 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const abortSimulation = createMockCommand('abort', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); - return success(); + return Promise.resolve(success()); }, }); const next = createMockCommand('next'); @@ -923,7 +1008,7 @@ describe('GridCommands', () => { let isExecutingAfterRejection = true; const blockingCommand = createMockCommand('blocking', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => async (): Promise => { try { await gridCommands.executeCommands([]); } catch { @@ -949,7 +1034,7 @@ describe('GridCommands', () => { describe('customizeResponseText', () => { it('should use default messages when customizeResponseText is not provided', async () => { const command = createMockCommand('test', { - execute: (_comp, { success }) => async () => success('default msg'), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success('default msg')), }); const gridCommands = new GridCommands(createMockComponent(), [command]); @@ -985,10 +1070,10 @@ describe('GridCommands', () => { }); const successCommand = createMockCommand('ok', { - execute: (_comp, { success }) => async () => success('default'), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success('default')), }); const failCommand = createMockCommand('fail', { - execute: (_comp, { failure }) => async () => failure('default'), + execute: (_comp, { failure }) => (): Promise => Promise.resolve(failure('default')), }); const gridCommands = new GridCommands( createMockComponent(), @@ -1013,7 +1098,7 @@ describe('GridCommands', () => { }); const failCommand = createMockCommand('fail', { - execute: (_comp, { failure }) => async () => failure('default failure'), + execute: (_comp, { failure }) => (): Promise => Promise.resolve(failure('default failure')), }); const gridCommands = new GridCommands(createMockComponent(), [failCommand]); @@ -1031,7 +1116,7 @@ describe('GridCommands', () => { }); const successCommand = createMockCommand('ok', { - execute: (_comp, { success }) => async () => success('default success'), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success('default success')), }); const gridCommands = new GridCommands(createMockComponent(), [successCommand]); @@ -1047,7 +1132,7 @@ describe('GridCommands', () => { const customizeResponseText: CustomizeResponseText = () => undefined; const command = createMockCommand('test', { - execute: (_comp, { success }) => async () => success('original'), + execute: (_comp, { success }) => (): Promise => Promise.resolve(success('original')), }); const gridCommands = new GridCommands(createMockComponent(), [command]); @@ -1068,9 +1153,9 @@ describe('GridCommands', () => { let gridCommands: GridCommands; const abortSimulation = createMockCommand('abort', { - execute: (_comp, { success }) => async () => { + execute: (_comp, { success }) => (): Promise => { gridCommands.abort(); - return success(); + return Promise.resolve(success()); }, }); const skipped = createMockCommand('skipped'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts index 28b86bd63863..7529fb0e96d9 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts @@ -4,10 +4,13 @@ import { import type { Message } from '@js/ui/chat'; import { AI_ASSISTANT_AUTHOR_ID, MessageStatus } from '../const'; +import type { JsonSchema } from '../types'; import { + expandTypeArraysToAnyOf, getMessageStatus, hasAbortedCommands, hasCommandErrors, + hoistSchemaRefs, isAIMessage, isChatOptions, isEnabledOption, @@ -255,3 +258,479 @@ describe('getMessageStatus', () => { expect(getMessageStatus([])).toBe(MessageStatus.Success); }); }); + +describe('expandTypeArraysToAnyOf', () => { + it('returns undefined for undefined input', () => { + expect(expandTypeArraysToAnyOf(undefined)).toBeUndefined(); + }); + + it('returns schema unchanged when type is a plain string', () => { + const schema: JsonSchema = { type: 'string' }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ type: 'string' }); + }); + + it('returns schema unchanged when there is no type field', () => { + const schema: JsonSchema = { enum: ['a', 'b'] }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ enum: ['a', 'b'] }); + }); + + it('converts array-style type to anyOf', () => { + const schema: JsonSchema = { type: ['string', 'number'] }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + + it('preserves sibling fields when converting type array', () => { + const schema: JsonSchema = { + type: ['string', 'null'], + description: 'A value', + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + anyOf: [{ type: 'string' }, { type: 'null' }], + description: 'A value', + }); + }); + + it('converts type array with a single element', () => { + const schema: JsonSchema = { type: ['string'] }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + anyOf: [{ type: 'string' }], + }); + }); + + it('recurses into properties', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + value: { type: ['string', 'number', 'boolean', 'null'] }, + }, + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + type: 'object', + properties: { + value: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' }, + ], + }, + }, + }); + }); + + it('recurses into anyOf array', () => { + const schema: JsonSchema = { + anyOf: [ + { type: ['string', 'null'] }, + { type: 'number' }, + ], + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + anyOf: [ + { anyOf: [{ type: 'string' }, { type: 'null' }] }, + { type: 'number' }, + ], + }); + }); + + it('recurses into items (object)', () => { + const schema: JsonSchema = { + type: 'array', + items: { type: ['string', 'number'] }, + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + type: 'array', + items: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + }); + }); + + it('recurses into items (array of schemas)', () => { + const schema: JsonSchema = { + type: 'array', + items: [ + { type: ['string', 'number'] }, + { type: 'boolean' }, + ], + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + type: 'array', + items: [ + { anyOf: [{ type: 'string' }, { type: 'number' }] }, + { type: 'boolean' }, + ], + }); + }); + + it('recurses into $defs', () => { + const schema: JsonSchema = { + type: 'object', + $defs: { + myType: { type: ['string', 'null'] }, + }, + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + type: 'object', + $defs: { + myType: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }); + }); + + it('handles deeply nested structures', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + filter: { + anyOf: [ + { + type: 'object', + properties: { + value: { type: ['string', 'number', 'boolean', 'null'] }, + }, + }, + ], + }, + }, + }; + expect(expandTypeArraysToAnyOf(schema)).toEqual({ + type: 'object', + properties: { + filter: { + anyOf: [ + { + type: 'object', + properties: { + value: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' }, + ], + }, + }, + }, + ], + }, + }, + }); + }); + + it('does not mutate the original schema', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + x: { type: ['string', 'number'] }, + }, + }; + const original = JSON.parse(JSON.stringify(schema)); + expandTypeArraysToAnyOf(schema); + expect(schema).toEqual(original); + }); +}); + +describe('hoistSchemaRefs', () => { + it('returns empty object when no schemas have $ref', () => { + const schema: JsonSchema = { + type: 'object', + properties: { x: { type: 'string' } }, + }; + + const result = hoistSchemaRefs([{ prefix: 'test', schema }]); + + expect(result).toEqual({}); + expect(schema.$defs).toBeUndefined(); + }); + + it('returns empty object for empty input array', () => { + expect(hoistSchemaRefs([])).toEqual({}); + }); + + describe('$defs-based refs', () => { + it('hoists $defs and removes them from the sub-schema', () => { + const schema: JsonSchema = { + type: 'object', + $defs: { + MyType: { type: 'string' }, + }, + properties: { + value: { $ref: '#/$defs/MyType' }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'test', schema }, + ]); + + expect(result).toEqual({ + test_MyType: { type: 'string' }, + }); + expect(schema.$defs).toBeUndefined(); + const props = schema.properties as JsonSchema; + expect(props.value).toEqual({ + $ref: '#/$defs/test_MyType', + }); + }); + + it('rewrites $ref inside $defs themselves', () => { + const schema: JsonSchema = { + type: 'object', + $defs: { + Expr: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + child: { $ref: '#/$defs/Expr' }, + }, + }, + ], + }, + }, + properties: { + root: { $ref: '#/$defs/Expr' }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'filter', schema }, + ]); + + expect(result).toEqual({ + filter_Expr: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + child: { $ref: '#/$defs/filter_Expr' }, + }, + }, + ], + }, + }); + const props = schema.properties as JsonSchema; + expect(props.root).toEqual({ + $ref: '#/$defs/filter_Expr', + }); + }); + + it('avoids collisions via prefix', () => { + const schemaA: JsonSchema = { + type: 'object', + $defs: { Shared: { type: 'number' } }, + properties: { a: { $ref: '#/$defs/Shared' } }, + }; + const schemaB: JsonSchema = { + type: 'object', + $defs: { Shared: { type: 'string' } }, + properties: { b: { $ref: '#/$defs/Shared' } }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'sortA', schema: schemaA }, + { prefix: 'sortB', schema: schemaB }, + ]); + + expect(result).toEqual({ + sortA_Shared: { type: 'number' }, + sortB_Shared: { type: 'string' }, + }); + const propsA = schemaA.properties as JsonSchema; + const propsB = schemaB.properties as JsonSchema; + expect(propsA.a).toEqual({ + $ref: '#/$defs/sortA_Shared', + }); + expect(propsB.b).toEqual({ + $ref: '#/$defs/sortB_Shared', + }); + }); + + it('handles schemas with multiple $defs entries', () => { + const schema: JsonSchema = { + type: 'object', + $defs: { + TypeA: { type: 'string' }, + TypeB: { type: 'number' }, + }, + properties: { + a: { $ref: '#/$defs/TypeA' }, + b: { $ref: '#/$defs/TypeB' }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'sort', schema }, + ]); + + expect(result).toEqual({ + sort_TypeA: { type: 'string' }, + sort_TypeB: { type: 'number' }, + }); + const props = schema.properties as JsonSchema; + expect(props.a).toEqual({ + $ref: '#/$defs/sort_TypeA', + }); + expect(props.b).toEqual({ + $ref: '#/$defs/sort_TypeB', + }); + }); + }); + + describe('inline-path refs', () => { + it('hoists an inline $ref to root-level $defs', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + expr: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + child: { $ref: '#/properties/expr' }, + }, + }, + ], + }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'test', schema }, + ]); + + expect(result.test_properties_expr).toBeDefined(); + + const props = schema.properties as JsonSchema; + const exprNode = props.expr as JsonSchema; + const branches = exprNode.anyOf as JsonSchema[]; + expect(branches[1].properties).toEqual({ + child: { $ref: '#/$defs/test_properties_expr' }, + }); + }); + + it('handles recursive inline $ref', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + expression: { + anyOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + type: { type: 'string', const: 'basic' }, + field: { type: 'string' }, + }, + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { type: 'string', const: 'combined' }, + left: { $ref: '#/properties/expression/anyOf/0' }, + right: { $ref: '#/properties/expression/anyOf/0' }, + }, + additionalProperties: false, + }, + ], + }, + { type: 'null' }, + ], + }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'filter', schema }, + ]); + + const defName = 'filter_properties_expression_anyOf_0'; + expect(result[defName]).toBeDefined(); + + const props = schema.properties as JsonSchema; + const exprNode = props.expression as JsonSchema; + const exprAnyOf = exprNode.anyOf as JsonSchema[]; + const innerAnyOf = exprAnyOf[0].anyOf as JsonSchema[]; + expect(innerAnyOf[1].properties).toEqual( + expect.objectContaining({ + left: { $ref: `#/$defs/${defName}` }, + right: { $ref: `#/$defs/${defName}` }, + }), + ); + + const hoistedDef = result[defName] as JsonSchema; + const hoistedInner = hoistedDef.anyOf as JsonSchema[]; + const hoistedProps = hoistedInner[1].properties as JsonSchema; + expect(hoistedProps.left).toEqual({ + $ref: `#/$defs/${defName}`, + }); + expect(hoistedProps.right).toEqual({ + $ref: `#/$defs/${defName}`, + }); + }); + }); + + it('skips schemas without $ref', () => { + const schemaWithRefs: JsonSchema = { + type: 'object', + $defs: { Foo: { type: 'boolean' } }, + properties: { x: { $ref: '#/$defs/Foo' } }, + }; + const schemaWithout: JsonSchema = { + type: 'object', + properties: { y: { type: 'string' } }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'a', schema: schemaWithRefs }, + { prefix: 'b', schema: schemaWithout }, + ]); + + expect(result).toEqual({ + a_Foo: { type: 'boolean' }, + }); + expect(schemaWithout).toEqual({ + type: 'object', + properties: { y: { type: 'string' } }, + }); + }); + + it('rewrites deeply nested $ref in arrays', () => { + const schema: JsonSchema = { + type: 'object', + $defs: { + Item: { type: 'string' }, + }, + properties: { + list: { + type: 'array', + items: { + anyOf: [ + { $ref: '#/$defs/Item' }, + { type: 'null' }, + ], + }, + }, + }, + }; + + const result = hoistSchemaRefs([ + { prefix: 'p', schema }, + ]); + + expect(result).toEqual({ p_Item: { type: 'string' } }); + + const props = schema.properties as JsonSchema; + const listNode = props.list as JsonSchema; + const items = listNode.items as JsonSchema; + const itemsAnyOf = items.anyOf as JsonSchema[]; + expect(itemsAnyOf[0]).toEqual({ $ref: '#/$defs/p_Item' }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts index 282178c3fb15..531ac32e732d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts @@ -20,6 +20,18 @@ import { filterValueCommand, } from '../filtering'; +const basicExpr = ( + field: string, + operator: string, + value: unknown, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +): any => ({ + type: 'basic', + field, + operator, + value, +}); + const createCallbacks = (): { success: jest.Mock<(message?: string) => CommandResult>; failure: jest.Mock<(message?: string) => CommandResult>; @@ -52,9 +64,9 @@ describe('filterValueCommand', () => { afterEach(() => afterTest()); describe('schema', () => { - it('accepts a basic [field, op, value] expression', () => { + it('accepts a basic expression', () => { expect(filterValueCommand.schema.safeParse({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), }).success).toBe(true); }); @@ -63,44 +75,66 @@ describe('filterValueCommand', () => { ['contains'], ['notcontains'], ['startswith'], ['endswith'], ])('accepts op "%s"', (op) => { expect(filterValueCommand.schema.safeParse({ - expression: ['name', op, 'Alpha'], + expression: basicExpr('name', op, 'Alpha'), }).success).toBe(true); }); it.each([ - [['name', '=', 'Alpha']], - [['name', '=', 1]], - [['name', '=', true]], - [['name', '=', null]], + [basicExpr('name', '=', 'Alpha')], + [basicExpr('name', '=', 1)], + [basicExpr('name', '=', true)], + [basicExpr('name', '=', null)], ])('accepts scalar value %p', (expression) => { expect(filterValueCommand.schema.safeParse({ expression }).success).toBe(true); }); - it('accepts a combined [expr, "and", expr] expression', () => { + it('accepts a combined expression with "and"', () => { expect(filterValueCommand.schema.safeParse({ - expression: [['name', '=', 'Alpha'], 'and', ['age', '>', 10]], + expression: { + type: 'combined', + left: basicExpr('name', '=', 'Alpha'), + combiner: 'and', + right: basicExpr('age', '>', 10), + }, }).success).toBe(true); }); - it('accepts a combined [expr, "or", expr] expression', () => { + it('accepts a combined expression with "or"', () => { expect(filterValueCommand.schema.safeParse({ - expression: [['name', '=', 'Alpha'], 'or', ['name', '=', 'Beta']], + expression: { + type: 'combined', + left: basicExpr('name', '=', 'Alpha'), + combiner: 'or', + right: basicExpr('name', '=', 'Beta'), + }, }).success).toBe(true); }); - it('accepts a negated ["!", expr] expression', () => { + it('accepts a negated expression', () => { expect(filterValueCommand.schema.safeParse({ - expression: ['!', ['name', '=', 'Alpha']], + expression: { + type: 'negated', + expression: basicExpr('name', '=', 'Alpha'), + }, }).success).toBe(true); }); it('accepts deeply nested expressions', () => { expect(filterValueCommand.schema.safeParse({ - expression: [ - ['!', ['name', '=', 'Alpha']], - 'and', - [['age', '>', 10], 'or', ['age', '<', 30]], - ], + expression: { + type: 'combined', + left: { + type: 'negated', + expression: basicExpr('name', '=', 'Alpha'), + }, + combiner: 'and', + right: { + type: 'combined', + left: basicExpr('age', '>', 10), + combiner: 'or', + right: basicExpr('age', '<', 30), + }, + }, }).success).toBe(true); }); @@ -114,38 +148,43 @@ describe('filterValueCommand', () => { it('rejects an unknown op', () => { expect(filterValueCommand.schema.safeParse({ - expression: ['name', 'like', 'Alpha'], + expression: basicExpr('name', 'like', 'Alpha'), }).success).toBe(false); }); it('rejects an unknown combiner', () => { expect(filterValueCommand.schema.safeParse({ - expression: [['name', '=', 'Alpha'], 'xor', ['age', '>', 10]], + expression: { + type: 'combined', + left: basicExpr('name', '=', 'Alpha'), + combiner: 'xor', + right: basicExpr('age', '>', 10), + }, }).success).toBe(false); }); it('rejects an object value (non-scalar)', () => { expect(filterValueCommand.schema.safeParse({ - expression: ['name', '=', { foo: 1 }], + expression: basicExpr('name', '=', { foo: 1 }), }).success).toBe(false); }); it('rejects unknown properties', () => { expect(filterValueCommand.schema.safeParse({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), extra: 1, }).success).toBe(false); }); }); describe('execute', () => { - it('calls component.option("filterValue", expression) exactly once', async () => { + it('calls component.option("filterValue", expression) with array format', async () => { const instance = await createGrid(); const spy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); const result = await filterValueCommand.execute(instance, callbacks)({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), }); expect(spy).toHaveBeenCalledWith('filterValue', ['name', '=', 'Alpha']); @@ -173,7 +212,7 @@ describe('filterValueCommand', () => { const callbacks = createCallbacks(); const result = await filterValueCommand.execute(instance, callbacks)({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), }); expect(result.status).toBe('failure'); @@ -186,7 +225,7 @@ describe('filterValueCommand', () => { const callbacks = createCallbacks(); await filterValueCommand.execute(instance, callbacks)({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), }); expect(callbacks.success).toHaveBeenCalledWith('Apply a filter.'); @@ -211,7 +250,7 @@ describe('filterValueCommand', () => { const callbacks = createCallbacks(); await filterValueCommand.execute(instance, callbacks)({ - expression: ['name', '=', 'Alpha'], + expression: basicExpr('name', '=', 'Alpha'), }); expect(callbacks.failure).toHaveBeenCalledWith('Apply a filter.'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/focus.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/focus.test.ts index 4108705c571e..ce85f5674d4b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/focus.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/focus.test.ts @@ -75,8 +75,8 @@ describe('focusRowByKeyCommand', () => { expect(focusRowByKeyCommand.schema.safeParse({ key }).success).toBe(true); }); - it('accepts an object key with string/number values', () => { - expect(focusRowByKeyCommand.schema.safeParse({ key: { a: 1, b: 'two' } }).success).toBe(true); + it('accepts an array-of-pairs key for composite keyExpr', () => { + expect(focusRowByKeyCommand.schema.safeParse({ key: [{ field: 'a', value: 1 }, { field: 'b', value: 'two' }] }).success).toBe(true); }); it('rejects when key is missing', () => { @@ -88,6 +88,7 @@ describe('focusRowByKeyCommand', () => { [null], [{ a: true }], [{ a: null }], + [{ a: 1, b: 'two' }], ])('rejects unsupported key value %p', (key) => { expect(focusRowByKeyCommand.schema.safeParse({ key }).success).toBe(false); }); @@ -122,7 +123,7 @@ describe('focusRowByKeyCommand', () => { const optionSpy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); - const result = await focusRowByKeyCommand.execute(instance, callbacks)({ key: { id: 2 } }); + const result = await focusRowByKeyCommand.execute(instance, callbacks)({ key: [{ field: 'id', value: 2 }] }); expect(result.status).toBe('failure'); expectFocusedRowKeyNotSet(optionSpy as unknown as jest.Mock); @@ -144,7 +145,7 @@ describe('focusRowByKeyCommand', () => { const optionSpy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); - const result = await focusRowByKeyCommand.execute(instance, callbacks)({ key: { a: 1 } }); + const result = await focusRowByKeyCommand.execute(instance, callbacks)({ key: [{ field: 'a', value: 1 }] }); expect(result.status).toBe('failure'); expectFocusedRowKeyNotSet(optionSpy as unknown as jest.Mock); @@ -187,7 +188,7 @@ describe('focusRowByKeyCommand', () => { const callbacks = createCallbacks(); const result = await focusRowByKeyCommand.execute(instance, callbacks)({ - key: { a: 1, b: 10 }, + key: [{ field: 'a', value: 1 }, { field: 'b', value: 10 }], }); expect(optionSpy).toHaveBeenCalledWith('focusedRowKey', { a: 1, b: 10 }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts index 3ee14267e25a..7b8e5e8fd29b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts @@ -80,8 +80,8 @@ describe('selectByKeysCommand', () => { expect(selectByKeysCommand.schema.safeParse({ keys, preserve: false }).success).toBe(true); }); - it('accepts object keys for composite keyExpr', () => { - const keys = [{ a: 1, b: 10 }]; + it('accepts array-of-pairs keys for composite keyExpr', () => { + const keys = [[{ field: 'a', value: 1 }, { field: 'b', value: 10 }]]; expect(selectByKeysCommand.schema.safeParse({ keys, preserve: true }).success).toBe(true); }); @@ -141,13 +141,13 @@ describe('selectByKeysCommand', () => { expect(selectSpy).not.toHaveBeenCalled(); }); - it('returns failure when an object key is passed for a single-field keyExpr', async () => { + it('returns failure when a composite key is passed for a single-field keyExpr', async () => { const instance = await createGrid(); const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); const result = await selectByKeysCommand.execute(instance, callbacks)({ - keys: [{ id: 1 }], + keys: [[{ field: 'id', value: 1 }]], preserve: false, }); @@ -175,7 +175,7 @@ describe('selectByKeysCommand', () => { const callbacks = createCallbacks(); const result = await selectByKeysCommand.execute(instance, callbacks)({ - keys: [{ a: 1 }], + keys: [[{ field: 'a', value: 1 }]], preserve: false, }); @@ -190,7 +190,10 @@ describe('selectByKeysCommand', () => { // First key is valid, second is missing field 'b' const result = await selectByKeysCommand.execute(instance, callbacks)({ - keys: [{ a: 1, b: 10 }, { a: 2 }], + keys: [ + [{ field: 'a', value: 1 }, { field: 'b', value: 10 }], + [{ field: 'a', value: 2 }], + ], preserve: false, }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts index 06a3562e2b44..58e67437c9c6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts @@ -9,27 +9,80 @@ const FILTER_OPS = [ 'contains', 'notcontains', 'startswith', 'endswith', ] as const satisfies readonly SearchOperation[]; -type FilterExpr = | [string, SearchOperation, string | number | boolean | null] - | [FilterExpr, 'and' | 'or', FilterExpr] - | ['!', FilterExpr]; +interface BasicFilterExpr { + type: 'basic'; + field: string; + operator: typeof FILTER_OPS[number]; + value: string | number | boolean | null; +} + +interface CombinedFilterExpr { + type: 'combined'; + left: FilterExprObj; + combiner: 'and' | 'or'; + right: FilterExprObj; +} + +interface NegatedFilterExpr { + type: 'negated'; + expression: FilterExprObj; +} + +type FilterExprObj = BasicFilterExpr | CombinedFilterExpr | NegatedFilterExpr; + +type FilterExprArray = | [string, SearchOperation, string | number | boolean | null] + | [FilterExprArray, 'and' | 'or', FilterExprArray] + | ['!', FilterExprArray]; const filterOpSchema = z.enum(FILTER_OPS); const filterValueScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); -const filterExprSchema: z.ZodType = z.lazy(() => z.union([ - z.tuple([z.string(), filterOpSchema, filterValueScalarSchema]), - z.tuple([filterExprSchema, z.enum(['and', 'or']), filterExprSchema]), - z.tuple([z.literal('!'), filterExprSchema]), +const basicFilterExprSchema = z.object({ + type: z.literal('basic'), + field: z.string(), + operator: filterOpSchema, + value: filterValueScalarSchema, +}).strict(); + +const filterExprSchema: z.ZodType = z.lazy(() => z.union([ + basicFilterExprSchema, + z.object({ + type: z.literal('combined'), + left: filterExprSchema, + combiner: z.enum(['and', 'or']), + right: filterExprSchema, + }).strict(), + z.object({ + type: z.literal('negated'), + expression: filterExprSchema, + }).strict(), ])); const filterValueCommandSchema = z.object({ expression: filterExprSchema.nullable(), }).strict(); +function convertFilterExprToArray(expr: FilterExprObj): FilterExprArray { + switch (expr.type) { + case 'basic': + return [expr.field, expr.operator, expr.value]; + case 'combined': + return [ + convertFilterExprToArray(expr.left), + expr.combiner, + convertFilterExprToArray(expr.right), + ]; + case 'negated': + return ['!', convertFilterExprToArray(expr.expression)]; + default: + throw new Error('Unknown filter expression type'); + } +} + export const filterValueCommand = defineGridCommand({ name: 'filterValue', - description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. Expression forms: basic [dataField, op, value], combined [expr, "and"|"or", expr], negated ["!", expr]. The first element of a basic expression is the column dataField (not the caption). Supported ops: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". To express "not and" / "not or", wrap the group in negation: ["!", [a, "and", b]].', + description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. Expression forms: basic {"type":"basic","field":dataField,"operator":op,"value":val}, combined {"type":"combined","left":expr,"combiner":"and"|"or","right":expr}, negated {"type":"negated","expression":expr}. The "field" is the column dataField (not the caption). Supported operators: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". To express "not and" / "not or", wrap the group in negation: {"type":"negated","expression":{"type":"combined",...}}.', schema: filterValueCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const defaultMessage = args.expression === null @@ -37,8 +90,12 @@ export const filterValueCommand = defineGridCommand({ : 'Apply a filter.'; try { + const filterValue = args.expression === null + ? undefined + : convertFilterExprToArray(args.expression); + // Handles remote operations via data controller listening for the `filtering` change - component.option('filterValue', args.expression ?? undefined); + component.option('filterValue', filterValue); return Promise.resolve(success(defaultMessage)); } catch { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts index adbbfbdb7fa2..7629966d552b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts @@ -3,7 +3,7 @@ import type { InternalGrid } from '@ts/grids/grid_core/m_types'; import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; -import { isKeyShapeValid } from './utils'; +import { compositeKeyPairSchema, isKeyShapeValid, normalizeKey } from './utils'; const setFocusedRowKeyAndSettle = async ( component: InternalGrid, @@ -42,13 +42,13 @@ const focusRowByKeyCommandSchema = z.object({ key: z.union([ z.string(), z.number(), - z.record(z.union([z.string(), z.number()])), + z.array(compositeKeyPairSchema), ]), }).strict(); export const focusRowByKeyCommand = defineGridCommand({ name: 'focusRowByKey', - description: 'Focus a specific row by its key value. The key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an object whose property names match each keyExpr field for a composite key. Requires focusedRowEnabled to be true on the grid.', + description: 'Focus a specific row by its key value. The key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an array of {field, value} pairs for a composite key. Requires focusedRowEnabled to be true on the grid.', schema: focusRowByKeyCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { const defaultMessage = 'Focus row.'; @@ -60,12 +60,14 @@ export const focusRowByKeyCommand = defineGridCommand({ const keyExpr = component.option('keyExpr') ?? component.getDataSource()?.store()?.key(); - if (!keyExpr || !isKeyShapeValid(keyExpr, args.key)) { + const key = normalizeKey(args.key); + + if (!keyExpr || !isKeyShapeValid(keyExpr, key)) { return failure(defaultMessage); } try { - await setFocusedRowKeyAndSettle(component, args.key); + await setFocusedRowKeyAndSettle(component, key); return success(defaultMessage); } catch { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index 6ac3949671aa..24df92995740 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts @@ -2,20 +2,20 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; -import { isKeyShapeValid } from './utils'; +import { compositeKeyPairSchema, isKeyShapeValid, normalizeKey } from './utils'; const selectByKeysCommandSchema = z.object({ keys: z.array(z.union([ z.string(), z.number(), - z.record(z.union([z.string(), z.number()])), + z.array(compositeKeyPairSchema), ])), preserve: z.boolean(), }).strict(); export const selectByKeysCommand = defineGridCommand({ name: 'selectByKeys', - description: 'Select rows by their key values. Each key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an object whose property names match each keyExpr field for a composite key. Set preserve to true to keep existing selection, or false to replace it.', + description: 'Select rows by their key values. Each key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an array of {field, value} pairs for a composite key. Set preserve to true to keep existing selection, or false to replace it.', schema: selectByKeysCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { const defaultMessage = 'Select row(s).'; @@ -27,12 +27,14 @@ export const selectByKeysCommand = defineGridCommand({ const keyExpr = component.option('keyExpr') ?? component.getDataSource()?.store()?.key(); - if (!keyExpr || args.keys.some((key) => !isKeyShapeValid(keyExpr, key))) { + const keys = args.keys.map(normalizeKey); + + if (!keyExpr || keys.some((key) => !isKeyShapeValid(keyExpr, key))) { return failure(defaultMessage); } try { - await component.selectRows(args.keys, args.preserve); + await component.selectRows(keys, args.preserve); return success(defaultMessage); } catch { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts index e9dc7c127a87..54a407b3af44 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts @@ -1,7 +1,37 @@ import { isString } from '@js/core/utils/type'; +import { z } from 'zod'; type RowKey = string | number | Record; +interface CompositeKeyPair { field: string; value: string | number } + +export const compositeKeyPairSchema = z.object({ + field: z.string(), + value: z.union([z.string(), z.number()]), +}).strict(); + +export const compositeKeyToObject = ( + pairs: CompositeKeyPair[], +): Record => { + const result: Record = {}; + + for (const { field, value } of pairs) { + result[field] = value; + } + + return result; +}; + +export const normalizeKey = ( + key: string | number | CompositeKeyPair[], +): RowKey => { + if (Array.isArray(key)) { + return compositeKeyToObject(key); + } + + return key; +}; + // Validates the user-supplied key against the grid's keyExpr shape. // Caller is responsible for resolving keyExpr first — passing `undefined` // would mean "no row key configured", which is a different failure case. diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts index 9dc0f0735bfc..083bad20d2d8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts @@ -16,7 +16,9 @@ import type { CustomizeResponseText, GridCommand, JsonSchema, + ResponseSchemaBranch, } from './types'; +import { expandTypeArraysToAnyOf, hoistSchemaRefs } from './utils'; export class GridCommands { private readonly component: InternalGrid; @@ -79,30 +81,57 @@ export class GridCommands { return this.executing; } - public buildResponseSchema(): JsonSchema { - const branches = [...this.commands.values()].map((command) => { - const argsSchema = zodToJsonSchema(command.schema, { target: 'jsonSchema7' }); + private buildResponseSchemaBranches(): ResponseSchemaBranch[] { + const commands = [...this.commands.values()]; + + return commands.map((command) => { + const argsSchema = zodToJsonSchema(command.schema, { target: 'openAi' }) as JsonSchema; // Remove $schema from nested schemas since it's only necessary at root delete argsSchema.$schema; + const expandedArgsSchema = expandTypeArraysToAnyOf(argsSchema); + return { - type: 'object', - description: command.description, - required: ['name', 'args'], - additionalProperties: false, - properties: { - name: { - type: 'string', - enum: [command.name], + commandName: command.name, + branch: { + type: 'object', + description: command.description, + required: ['name', 'args'], + additionalProperties: false, + properties: { + name: { + type: 'string', + enum: [command.name], + }, + args: expandedArgsSchema, }, - args: argsSchema, }, }; }); + } - return { - $schema: 'http://json-schema.org/draft-07/schema#', + private enrichSchemaWithDefs( + schema: JsonSchema, + branches: ResponseSchemaBranch[], + ): void { + // Hoist $ref targets to root-level $defs (required by OpenAI) + const defsInputs = branches.map(({ commandName, branch }) => ({ + prefix: commandName, + schema: branch.properties.args as JsonSchema, + })); + const mergedDefs = hoistSchemaRefs(defsInputs); + + if (Object.keys(mergedDefs).length > 0) { + schema.$defs = mergedDefs; + } + } + + public buildResponseSchema(): JsonSchema { + const branches = this.buildResponseSchemaBranches(); + const itemsAnyOfSchema = branches.map(({ branch }) => branch); + + const schema: JsonSchema = { type: 'object', required: ['actions'], additionalProperties: false, @@ -111,11 +140,15 @@ export class GridCommands { type: 'array', description: 'The list of grid commands and corresponding arguments to execute', items: { - anyOf: branches, + anyOf: itemsAnyOfSchema, }, }, }, }; + + this.enrichSchemaWithDefs(schema, branches); + + return schema; } public validate(actions: ExecuteGridAssistantAction[]): boolean { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index 4952accc565b..ef2fe8a5f48b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -2,14 +2,25 @@ import type { RequestCallbacks } from '@js/common/ai-integration'; import type { Message } from '@js/ui/chat'; import type { InternalGrid } from '@ts/grids/grid_core/m_types'; import type { z, ZodObject, ZodRawShape } from 'zod'; -import type { JsonSchema7Type } from 'zod-to-json-schema'; import type { MessageStatus } from './const'; -/** JSON Schema draft-07 object sent to the LLM. */ -export type JsonSchema = JsonSchema7Type & { - $schema?: string; -}; +/** JSON Schema object sent to the LLM. */ +export type JsonSchema = Record; + +export interface ResponseSchemaBranch { + commandName: string; + branch: { + type: string; + description: string; + required: string[]; + additionalProperties: boolean; + properties: { + name: { type: string; enum: string[] }; + args: JsonSchema | undefined; + }; + }; +} export type CommandStatus = 'success' | 'failure' | 'aborted'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts index b87ced00d4c5..acf42b829d3d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts @@ -2,7 +2,7 @@ import { isObject } from '@js/core/utils/type'; import type { Message } from '@js/ui/chat'; import { AI_ASSISTANT_AUTHOR_ID, MessageStatus } from './const'; -import type { AIMessage, CommandResult } from './types'; +import type { AIMessage, CommandResult, JsonSchema } from './types'; export const isAIMessage = ( message: Message, @@ -40,3 +40,221 @@ export const getMessageStatus = (commands: CommandResult[]): MessageStatus => { return MessageStatus.Success; }; + +/** + * Recursively converts JSON Schema array-style `type` + * (e.g. `{"type": ["string", "number"]}`) to the equivalent `anyOf` form + * (e.g. `{"anyOf": [{"type": "string"}, {"type": "number"}]}`). + * + * Some structured-output APIs do not support array-style `type` fields + * and require explicit `anyOf` instead. + */ +export const expandTypeArraysToAnyOf = ( + schema: JsonSchema | undefined, +): JsonSchema | undefined => { + const SCHEMA_TRAVERSAL_KEYS = [ + 'properties', 'items', 'anyOf', 'oneOf', 'allOf', + 'additionalProperties', 'additionalItems', '$defs', 'definitions', + ] as const; + + const SCHEMA_MAP_KEYS = new Set(['properties', '$defs', 'definitions']); + + const transformNested = (key: string, value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map((item) => expandTypeArraysToAnyOf(item as JsonSchema)); + } + + if (!value || typeof value !== 'object') { + return value; + } + + if (SCHEMA_MAP_KEYS.has(key)) { + return Object.fromEntries( + Object.entries(value as JsonSchema).map( + ([k, v]) => [k, expandTypeArraysToAnyOf(v as JsonSchema)], + ), + ); + } + + return expandTypeArraysToAnyOf(value as JsonSchema); + }; + + if (!schema || typeof schema !== 'object') { + return schema; + } + + const result: JsonSchema = { ...schema }; + + if (Array.isArray(result.type)) { + const { type, ...rest } = result; + + return { + anyOf: (type as string[]).map((t) => ({ type: t })), + ...rest, + }; + } + + for (const key of SCHEMA_TRAVERSAL_KEYS) { + if (key in result) { + result[key] = transformNested(key, result[key]); + } + } + + return result; +}; + +/** Resolves a JSON Pointer (e.g. `#/properties/expr/anyOf/0`) within a schema. */ +const resolveJsonPointer = ( + schema: JsonSchema, + pointer: string, +): unknown => { + if (!pointer.startsWith('#/')) { + return undefined; + } + + const segments = pointer.slice(2).split('/'); + let current: unknown = schema; + + for (const segment of segments) { + if (!current || typeof current !== 'object') { + return undefined; + } + + current = (current as Record)[segment]; + } + + return current; +}; + +/** Recursively collects all `$ref` string values from a schema node. */ +const collectAllRefs = (node: unknown, refs: Set): void => { + if (!node || typeof node !== 'object') { + return; + } + + if (Array.isArray(node)) { + for (const item of node) { + collectAllRefs(item, refs); + } + return; + } + + const obj = node as Record; + + if (typeof obj.$ref === 'string') { + refs.add(obj.$ref); + } + + for (const value of Object.values(obj)) { + collectAllRefs(value, refs); + } +}; + +/** Recursively rewrites `$ref` values using an `oldRef → newRef` mapping. Mutates in place. */ +const rewriteRefs = ( + node: unknown, + refMap: Map, +): void => { + if (!node || typeof node !== 'object') { + return; + } + + if (Array.isArray(node)) { + for (const item of node) { + rewriteRefs(item, refMap); + } + return; + } + + const obj = node as Record; + + if (typeof obj.$ref === 'string') { + const newRef = refMap.get(obj.$ref); + + if (newRef) { + obj.$ref = newRef; + } + } + + for (const value of Object.values(obj)) { + rewriteRefs(value, refMap); + } +}; + +/** + * Derives a definition name from a `$ref` path. + * + * `#/$defs/MyType` → `"MyType"`, + * `#/properties/expression/anyOf/0` → `"properties_expression_anyOf_0"` + */ +const defNameFromRef = (ref: string): string => { + if (ref.startsWith('#/$defs/')) { + return ref.slice(8); + } + + return ref.slice(2).replace(/\//g, '_'); +}; + +/** + * Extracts all `$ref` targets from each sub-schema, moves them into a + * merged top-level `$defs` map, and rewrites every `$ref` to point to + * `#/$defs/_`. + * + * Handles both `$defs`-based (`$ref: "#/$defs/Foo"`) and inline-path + * (`$ref: "#/properties/expression/anyOf/0"`) references that + * `zodToJsonSchema` may produce. + * + * OpenAI Structured Outputs requires every `$ref` to resolve against + * `$defs` at the **root** of the schema. This utility rewrites local + * references so the combined schema stays valid. + * + * **Mutates** the input items in place and returns the merged `$defs`. + */ +export const hoistSchemaRefs = ( + items: { prefix: string; schema: JsonSchema }[], +): JsonSchema => { + const mergedDefs: JsonSchema = {}; + + for (const { prefix, schema } of items) { + const refs = new Set(); + collectAllRefs(schema, refs); + + if (refs.size === 0) { + // eslint-disable-next-line no-continue + continue; + } + + const refMap = new Map(); + + for (const ref of refs) { + const baseName = defNameFromRef(ref); + const prefixedName = `${prefix}_${baseName}`; + const newRef = `#/$defs/${prefixedName}`; + + refMap.set(ref, newRef); + + const resolved = ref.startsWith('#/$defs/') + ? (schema.$defs as JsonSchema | undefined)?.[baseName] + : resolveJsonPointer(schema, ref); + + if (resolved && typeof resolved === 'object') { + mergedDefs[prefixedName] = JSON.parse( + JSON.stringify(resolved), + ); + } + } + + if (schema.$defs) { + delete schema.$defs; + } + + rewriteRefs(schema, refMap); + + for (const prefixedName of new Set(refMap.values())) { + const defName = prefixedName.slice('#/$defs/'.length); + rewriteRefs(mergedDefs[defName], refMap); + } + } + + return mergedDefs; +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts index 8f4c735f491c..661fd5d40497 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts @@ -940,8 +940,8 @@ describe('AIChat', () => { describe('getUserId', () => { it('should return user id from chat instance', () => { - mockChatInstance.option.mockImplementation((name: string) => { - if (name === 'user.id') return 'user-123'; + mockChatInstance.option.mockImplementation((...args: unknown[]) => { + if (args[0] === 'user.id') return 'user-123'; return undefined; });