From e97842fcb5c6c5dc052ce1f595d2b7e15e157803 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:45:29 +0400 Subject: [PATCH 1/6] test(grid): pin AI assistant response schema --- .../__tests__/response_schema.test.ts | 633 ++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts new file mode 100644 index 000000000000..311bec055db8 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, it } from '@jest/globals'; +import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands/index'; +import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; +import type { JsonSchema, ResponseSchemaBranch } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/types'; + +import { dataGridCommands } from '../commands/index'; + +const expectedArgsByCommand: Record = { + columnsPinning: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + fixed: { + type: 'boolean', + }, + fixedPosition: { + anyOf: [ + { + type: 'string', + enum: ['left', 'right'], + }, + { + type: 'null', + }, + ], + }, + }, + required: ['dataField', 'fixed', 'fixedPosition'], + additionalProperties: false, + }, + columnsReorder: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + visibleIndex: { + type: 'integer', + minimum: 0, + }, + }, + required: ['dataField', 'visibleIndex'], + additionalProperties: false, + }, + columnsResize: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + width: { + anyOf: [ + { + type: 'number', + }, + { + type: 'string', + }, + ], + }, + }, + required: ['dataField', 'width'], + additionalProperties: false, + }, + columnsVisibility: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + visible: { + type: 'boolean', + }, + }, + required: ['dataField', 'visible'], + additionalProperties: false, + }, + clearFilter: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + filterValue: { + type: 'object', + properties: { + expression: { + anyOf: [ + { + type: 'object', + properties: { + rootId: { + type: 'string', + }, + nodes: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + }, + expr: { + anyOf: [ + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['basic'], + }, + field: { + type: 'string', + }, + operator: { + type: 'string', + enum: ['=', '<>', '<', '<=', '>', '>=', 'contains', 'notcontains', 'startswith', 'endswith'], + }, + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + }, + required: ['type', 'field', 'operator', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['basic'], + }, + field: { + type: 'string', + }, + operator: { + type: 'string', + enum: ['anyof', 'noneof'], + }, + value: { + type: 'array', + items: { + $ref: '#/$defs/filterValue_properties_expression_anyOf_0_properties_nodes_items_properties_expr_anyOf_0_properties_value', + }, + }, + }, + required: ['type', 'field', 'operator', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['combined'], + }, + combiner: { + type: 'string', + enum: ['and', 'or'], + }, + leftId: { + type: 'string', + }, + rightId: { + type: 'string', + }, + }, + required: ['type', 'combiner', 'leftId', 'rightId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['negated'], + }, + expressionId: { + type: 'string', + }, + }, + required: ['type', 'expressionId'], + additionalProperties: false, + }, + ], + }, + }, + required: ['id', 'expr'], + additionalProperties: false, + }, + minItems: 1, + }, + }, + required: ['rootId', 'nodes'], + additionalProperties: false, + }, + { + type: 'null', + }, + ], + }, + }, + required: ['expression'], + additionalProperties: false, + }, + focusRowByIndex: { + type: 'object', + properties: { + index: { + type: 'integer', + minimum: 0, + }, + }, + required: ['index'], + additionalProperties: false, + }, + focusRowByKey: { + type: 'object', + properties: { + key: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'array', + items: { + type: 'object', + properties: { + field: { + type: 'string', + }, + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }, + required: ['field', 'value'], + additionalProperties: false, + }, + }, + ], + }, + }, + required: ['key'], + additionalProperties: false, + }, + pageIndex: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + }, + }, + required: ['pageIndex'], + additionalProperties: false, + }, + pageSize: { + type: 'object', + properties: { + pageSize: { + type: 'integer', + minimum: 0, + }, + }, + required: ['pageSize'], + additionalProperties: false, + }, + paging: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + }, + }, + required: ['enabled'], + additionalProperties: false, + }, + searching: { + type: 'object', + properties: { + text: { + type: 'string', + }, + }, + required: ['text'], + additionalProperties: false, + }, + clearSelection: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + deselectAll: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + selectAll: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + selectionByIndexes: { + type: 'object', + properties: { + indexes: { + type: 'array', + items: { + type: 'integer', + minimum: 1, + }, + minItems: 1, + }, + mode: { + type: 'string', + enum: ['select', 'deselect'], + }, + scope: { + type: 'string', + enum: ['allPages', 'page'], + }, + }, + required: ['indexes', 'mode', 'scope'], + additionalProperties: false, + }, + selectByKeys: { + type: 'object', + properties: { + keys: { + type: 'array', + items: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'array', + items: { + type: 'object', + properties: { + field: { + type: 'string', + }, + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }, + required: ['field', 'value'], + additionalProperties: false, + }, + }, + ], + }, + }, + preserve: { + type: 'boolean', + }, + }, + required: ['keys', 'preserve'], + additionalProperties: false, + }, + clearSorting: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + sorting: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + sortOrder: { + type: 'string', + enum: ['asc', 'desc', 'none'], + }, + }, + required: ['dataField', 'sortOrder'], + additionalProperties: false, + }, + grouping: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + groupIndex: { + anyOf: [ + { + type: 'integer', + minimum: 0, + }, + { + type: 'null', + }, + ], + }, + }, + required: ['dataField', 'groupIndex'], + additionalProperties: false, + }, + clearGrouping: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + summary: { + type: 'object', + properties: { + totalItems: { + type: 'array', + items: { + type: 'object', + properties: { + column: { + type: 'string', + }, + summaryType: { + type: 'string', + enum: ['sum', 'min', 'max', 'avg', 'count'], + }, + showInColumn: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, + displayFormat: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, + }, + required: ['column', 'summaryType', 'showInColumn', 'displayFormat'], + additionalProperties: false, + }, + }, + groupItems: { + type: 'array', + items: { + type: 'object', + properties: { + column: { + $ref: '#/$defs/summary_properties_totalItems_items_properties_column', + }, + summaryType: { + $ref: '#/$defs/summary_properties_totalItems_items_properties_summaryType', + }, + showInColumn: { + $ref: '#/$defs/summary_properties_totalItems_items_properties_showInColumn', + }, + displayFormat: { + $ref: '#/$defs/summary_properties_totalItems_items_properties_displayFormat', + }, + showInGroupFooter: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + alignByColumn: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + }, + required: ['column', 'summaryType', 'showInColumn', 'displayFormat', 'showInGroupFooter', 'alignByColumn'], + additionalProperties: false, + }, + }, + }, + required: ['totalItems', 'groupItems'], + additionalProperties: false, + }, + clearSummary: { + type: 'object', + properties: {}, + additionalProperties: false, + }, +}; + +const filterValueDefName = 'filterValue_properties_expression_anyOf_0_properties_nodes_items_properties_expr_anyOf_0_properties_value'; + +const expectedDataGridDefs: JsonSchema = { + [filterValueDefName]: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + summary_properties_totalItems_items_properties_column: { + type: 'string', + }, + summary_properties_totalItems_items_properties_summaryType: { + type: 'string', + enum: ['sum', 'min', 'max', 'avg', 'count'], + }, + summary_properties_totalItems_items_properties_showInColumn: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, + summary_properties_totalItems_items_properties_displayFormat: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, +}; + +const buildResponseSchema = (commands: typeof coreCommands): JsonSchema => new GridCommands( + {} as InternalGrid, + commands, +).buildResponseSchema(); + +const getArgsByCommand = (schema: JsonSchema): Record => { + const { actions } = schema.properties as { + actions: { items: { anyOf: ResponseSchemaBranch['branch'][] } }; + }; + + return Object.fromEntries(actions.items.anyOf.map( + (branch) => [branch.properties.name.enum[0], branch.properties.args], + )); +}; + +describe('Response schema', () => { + describe('DataGrid commands', () => { + it.each(Object.keys(expectedArgsByCommand))('should build args schema for the "%s" command', (commandName) => { + const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); + + const argsByCommand = getArgsByCommand(schema); + + expect(argsByCommand[commandName]).toEqual(expectedArgsByCommand[commandName]); + }); + + it('should build args for all commands', () => { + const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); + + const argsByCommand = getArgsByCommand(schema); + + expect(Object.keys(argsByCommand)).toEqual(Object.keys(expectedArgsByCommand)); + }); + + it('should build root $defs', () => { + const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); + + expect(schema.$defs).toEqual(expectedDataGridDefs); + }); + }); + + describe('TreeList commands', () => { + it('should build root $defs', () => { + const schema = buildResponseSchema(coreCommands); + + expect(schema.$defs).toEqual({ + [filterValueDefName]: expectedDataGridDefs[filterValueDefName], + }); + }); + }); +}); From bd6ef1e0a46f8e41a63bd82a8a01623946f15b61 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:53:02 +0400 Subject: [PATCH 2/6] chore(deps): migrate AI assistant to zod 4 --- NOTICE.txt | 4 - apps/demos/package.json | 1 - e2e/wrappers/package.json | 1 - .../__tests__/response_schema.test.ts | 173 ++++++------------ .../__tests__/grid_commands.test.ts | 2 +- .../grid_core/ai_assistant/commands/utils.ts | 17 +- .../grid_core/ai_assistant/grid_commands.ts | 9 +- .../grids/grid_core/ai_assistant/types.ts | 2 +- .../grids/grid_core/ai_assistant/utils.ts | 28 ++- packages/devextreme/package.json | 3 +- .../helpers/esm-shims/zod-to-json-schema.js | 8 - .../testing/helpers/esm-shims/zod.js | 1 + .../testing/runner/lib/importMap.ts | 1 - pnpm-lock.yaml | 57 ++---- pnpm-workspace.yaml | 3 +- 15 files changed, 124 insertions(+), 186 deletions(-) delete mode 100644 packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js diff --git a/NOTICE.txt b/NOTICE.txt index 590e5370ec6e..ceda2dbe9dd4 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -115,8 +115,4 @@ Zod (Open Source - MIT License) Copyright (c) 2025 Colin McDonnell https://github.com/colinhacks/zod/blob/main/LICENSE -zod-to-json-schema (Open Source - ISC License) -Copyright (c) 2020, Stefan Terdell -https://github.com/StefanTerdell/zod-to-json-schema/blob/master/LICENSE - The open source libraries included in this product are done so pursuant to each individual open source library license and subject to the disclaimers and limitations on liability set forth in each open source library license. diff --git a/apps/demos/package.json b/apps/demos/package.json index 049685dd55ac..fa18406c1f43 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -82,7 +82,6 @@ "whatwg-fetch": "2.0.4", "yargs": "17.7.2", "zod": "catalog:", - "zod-to-json-schema": "catalog:", "zone.js": "0.16.2", "express-rate-limit": "^8.3.1" }, diff --git a/e2e/wrappers/package.json b/e2e/wrappers/package.json index 34a6113952cd..8fa098c5c9df 100644 --- a/e2e/wrappers/package.json +++ b/e2e/wrappers/package.json @@ -50,7 +50,6 @@ "vue": "^3.5.13", "vue-router": "^4.3.0", "zod": "catalog:", - "zod-to-json-schema": "catalog:", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts index 311bec055db8..8df73ce401ac 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts @@ -6,6 +6,57 @@ import type { InternalGrid } from '@ts/grids/grid_core/types'; import { dataGridCommands } from '../commands/index'; +const expectedFilterScalarValue: JsonSchema = { + anyOf: [ + { + type: 'string', + description: 'A plain string value. Date values should be in "YYYY-MM-DDTHH:mm:ss" format (e.g. "2024-05-10T00:00:00", "2024-05-10T14:30:00"). The time part is always required. The "Z" suffix or timezone offset should not be appended unless the user explicitly requests it.', + }, + { + type: 'number', + description: 'A numeric filter value.', + }, + { + type: 'boolean', + description: 'A boolean filter value.', + }, + { + type: 'null', + description: 'A null filter value.', + }, + ], +}; + +const expectedSummaryItemBaseProperties: JsonSchema = { + column: { + type: 'string', + }, + summaryType: { + type: 'string', + enum: ['sum', 'min', 'max', 'avg', 'count'], + }, + showInColumn: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, + displayFormat: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, +}; + const expectedArgsByCommand: Record = { columnsPinning: { type: 'object', @@ -118,22 +169,7 @@ const expectedArgsByCommand: Record = { type: 'string', enum: ['=', '<>', '<', '<=', '>', '>=', 'contains', 'notcontains', 'startswith', 'endswith'], }, - value: { - anyOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - type: 'null', - }, - ], - }, + value: expectedFilterScalarValue, }, required: ['type', 'field', 'operator', 'value'], additionalProperties: false, @@ -154,9 +190,7 @@ const expectedArgsByCommand: Record = { }, value: { type: 'array', - items: { - $ref: '#/$defs/filterValue_properties_expression_anyOf_0_properties_nodes_items_properties_expr_anyOf_0_properties_value', - }, + items: expectedFilterScalarValue, }, }, required: ['type', 'field', 'operator', 'value'], @@ -447,35 +481,7 @@ const expectedArgsByCommand: Record = { type: 'array', items: { type: 'object', - properties: { - column: { - type: 'string', - }, - summaryType: { - type: 'string', - enum: ['sum', 'min', 'max', 'avg', 'count'], - }, - showInColumn: { - anyOf: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, - displayFormat: { - anyOf: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, - }, + properties: expectedSummaryItemBaseProperties, required: ['column', 'summaryType', 'showInColumn', 'displayFormat'], additionalProperties: false, }, @@ -485,18 +491,7 @@ const expectedArgsByCommand: Record = { items: { type: 'object', properties: { - column: { - $ref: '#/$defs/summary_properties_totalItems_items_properties_column', - }, - summaryType: { - $ref: '#/$defs/summary_properties_totalItems_items_properties_summaryType', - }, - showInColumn: { - $ref: '#/$defs/summary_properties_totalItems_items_properties_showInColumn', - }, - displayFormat: { - $ref: '#/$defs/summary_properties_totalItems_items_properties_displayFormat', - }, + ...expectedSummaryItemBaseProperties, showInGroupFooter: { anyOf: [ { @@ -533,54 +528,6 @@ const expectedArgsByCommand: Record = { }, }; -const filterValueDefName = 'filterValue_properties_expression_anyOf_0_properties_nodes_items_properties_expr_anyOf_0_properties_value'; - -const expectedDataGridDefs: JsonSchema = { - [filterValueDefName]: { - anyOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - type: 'null', - }, - ], - }, - summary_properties_totalItems_items_properties_column: { - type: 'string', - }, - summary_properties_totalItems_items_properties_summaryType: { - type: 'string', - enum: ['sum', 'min', 'max', 'avg', 'count'], - }, - summary_properties_totalItems_items_properties_showInColumn: { - anyOf: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, - summary_properties_totalItems_items_properties_displayFormat: { - anyOf: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, -}; - const buildResponseSchema = (commands: typeof coreCommands): JsonSchema => new GridCommands( {} as InternalGrid, commands, @@ -614,20 +561,18 @@ describe('Response schema', () => { expect(Object.keys(argsByCommand)).toEqual(Object.keys(expectedArgsByCommand)); }); - it('should build root $defs', () => { + it('should not build root $defs', () => { const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); - expect(schema.$defs).toEqual(expectedDataGridDefs); + expect(schema.$defs).toBeUndefined(); }); }); describe('TreeList commands', () => { - it('should build root $defs', () => { + it('should not build root $defs', () => { const schema = buildResponseSchema(coreCommands); - expect(schema.$defs).toEqual({ - [filterValueDefName]: expectedDataGridDefs[filterValueDefName], - }); + expect(schema.$defs).toBeUndefined(); }); }); }); 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 3d8d65d6ee97..35b4d93a84d8 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 @@ -393,7 +393,7 @@ describe('GridCommands', () => { op: filterOps, value: z.union([z.string(), z.number(), z.null()]), }).strict(); - const exprSchema: z.ZodType = z.lazy(() => z.union([ + const exprSchema: z.ZodType = z.lazy(() => z.union([ basicExpr, z.object({ type: z.literal('combined'), 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 ec55f1260a00..1bf7e64e62fb 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 @@ -37,18 +37,21 @@ export const normalizeKey = ( }; /* eslint-disable spellcheck/spell-checker */ -type OptionalNullishSchema = z.ZodEffects< - z.ZodOptional>, - z.output | undefined, - z.input | null | undefined +type OptionalNullishSchema = z.ZodOptional< + z.ZodPipe< + z.ZodOptional>, + z.ZodTransform< + z.output | undefined, + z.output | null | undefined + > + > >; // Treats `null` as "absent" for optional schema fields -export function optionalNullish( +export function optionalNullish( schema: T, ): OptionalNullishSchema { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return schema.nullish().transform((value) => value ?? undefined); + return schema.nullish().transform((value) => value ?? undefined).optional(); } /* eslint-enable spellcheck/spell-checker */ 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 39d9916155de..8a98a5643ad2 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 @@ -8,7 +8,7 @@ import { DEFAULT_SUCCESS_MESSAGE, EXECUTION_ABORT_MESSAGE, } from '@ts/grids/grid_core/ai_assistant/const'; -import { zodToJsonSchema } from 'zod-to-json-schema'; +import { z } from 'zod'; import type { InternalGrid } from '../types'; import type { @@ -19,7 +19,7 @@ import type { JsonSchema, ResponseSchemaBranch, } from './types'; -import { expandTypeArraysToAnyOf, hoistSchemaRefs } from './utils'; +import { expandTypeArraysToAnyOf, hoistSchemaRefs, makeOpenAICompatible } from './utils'; export class GridCommands { private readonly component: InternalGrid; @@ -85,7 +85,10 @@ export class GridCommands { const commands = [...this.commands.values()]; return commands.map((command) => { - const argsSchema = zodToJsonSchema(command.schema, { target: 'openAi' }) as JsonSchema; + const argsSchema = z.toJSONSchema(command.schema, { + io: 'input', + override: makeOpenAICompatible, + }) as JsonSchema; // Remove $schema from nested schemas since it's only necessary at root delete argsSchema.$schema; 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 f4e80e053b5f..6d3b5c4ead1a 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 @@ -44,7 +44,7 @@ export type CommandExecutor = ( // Empty schemas (no keys) collapse args to `undefined` so the executor // signature becomes `() => Promise` for no-arg commands. -type CommandArgs> = keyof z.infer extends never +type CommandArgs> = keyof TSchema['shape'] extends never ? undefined : z.infer; 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 66d4595dd08d..8252dadd2a45 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 @@ -6,6 +6,7 @@ import type { Message } from '@js/ui/chat'; import { custom } from '@js/ui/dialog'; import { current, isCompact, isFluent } from '@js/ui/themes'; import type { BaseDialog, DialogParams } from '@ts/ui/dialog'; +import type { z } from 'zod'; import { AI_ASSISTANT_AUTHOR_ID, @@ -51,6 +52,30 @@ export const getMessageStatus = (commands: CommandResult[]): ResponseStatus => { return 'success'; }; +export const makeOpenAICompatible = ( + { jsonSchema }: { jsonSchema: z.core.JSONSchema.BaseSchema }, +): void => { + if (jsonSchema.type === 'object' && jsonSchema.properties) { + const propertyNames = Object.keys(jsonSchema.properties); + + if (propertyNames.length > 0) { + jsonSchema.required = propertyNames; + } + + jsonSchema.additionalProperties ??= false; + } + + if (jsonSchema.type === 'integer') { + if (jsonSchema.minimum === Number.MIN_SAFE_INTEGER) { + delete jsonSchema.minimum; + } + + if (jsonSchema.maximum === Number.MAX_SAFE_INTEGER) { + delete jsonSchema.maximum; + } + } +}; + /** * Some structured-output APIs do not support array-style `type` fields * and require explicit `anyOf` instead. @@ -207,8 +232,7 @@ const defNameFromRef = (ref: string): string => { * `#/$defs/_`. * * Handles both `$defs`-based (`$ref: "#/$defs/Foo"`) and inline-path - * (`$ref: "#/properties/expression/anyOf/0"`) references that - * `zodToJsonSchema` may produce. + * (`$ref: "#/properties/expression/anyOf/0"`) references. * * OpenAI Structured Outputs requires every `$ref` to resolve against * `$defs` at the **root** of the schema. This utility rewrites local diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 24ab8eaedb4b..9bfb99fb244b 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -63,8 +63,7 @@ "inferno-create-element": "catalog:", "jszip": "^3.10.1", "rrule": "^2.7.1", - "zod": "catalog:", - "zod-to-json-schema": "catalog:" + "zod": "catalog:" }, "devDependencies": { "@babel/core": "7.29.7", diff --git a/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js b/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js deleted file mode 100644 index c1ce5b58b1a2..000000000000 --- a/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minimal zod-to-json-schema stub for QUnit ESM / import-map loader. - */ -export function zodToJsonSchema() { - return { type: 'object' }; -} - -export default zodToJsonSchema; diff --git a/packages/devextreme/testing/helpers/esm-shims/zod.js b/packages/devextreme/testing/helpers/esm-shims/zod.js index 489d0bbcf5ba..c3cef03a49b7 100644 --- a/packages/devextreme/testing/helpers/esm-shims/zod.js +++ b/packages/devextreme/testing/helpers/esm-shims/zod.js @@ -29,6 +29,7 @@ const z = { transform() { return z; }, describe() { return z; }, safeParse() { return { success: true, data: {} }; }, + toJSONSchema() { return { type: 'object' }; }, }; export { z }; diff --git a/packages/devextreme/testing/runner/lib/importMap.ts b/packages/devextreme/testing/runner/lib/importMap.ts index 84d2d0e175dd..ab39dcd72125 100644 --- a/packages/devextreme/testing/runner/lib/importMap.ts +++ b/packages/devextreme/testing/runner/lib/importMap.ts @@ -352,7 +352,6 @@ export function buildQunitImportMap({ // Stubs zod: `${SHIMS}/zod.js`, - 'zod-to-json-schema': `${SHIMS}/zod-to-json-schema.js`, // Suite-specific missing-dependency overrides ...collectSuiteImportOverrides(suiteFilePath), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b1de37ab436..dc597318f641 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,11 +149,8 @@ catalogs: specifier: 10.4.1 version: 10.4.1 zod: - specifier: 3.24.4 - version: 3.24.4 - zod-to-json-schema: - specifier: 3.24.6 - version: 3.24.6 + specifier: ^4.6.5 + version: 4.6.5 tools: '@types/node': specifier: 20.19.37 @@ -496,7 +493,7 @@ importers: version: 4.2.0 openai: specifier: 7.5.0 - version: 7.5.0(ws@8.21.1)(zod@3.24.4) + version: 7.5.0(ws@8.21.1)(zod@4.6.5) preact: specifier: 10.12.1 version: 10.12.1 @@ -565,10 +562,7 @@ importers: version: 17.7.2 zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 zone.js: specifier: 0.16.2 version: 0.16.2 @@ -1241,10 +1235,7 @@ importers: version: 4.6.4(vue@3.5.40(typescript@5.8.3)) zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 zone.js: specifier: ~0.15.0 version: 0.15.1 @@ -1350,10 +1341,7 @@ importers: version: 2.8.1 zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 devDependencies: '@babel/core': specifier: 7.29.7 @@ -16332,11 +16320,6 @@ packages: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} - zod-to-json-schema@3.24.6: - resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} - peerDependencies: - zod: ^3.24.1 - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -16348,9 +16331,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.24.4: - resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -16360,6 +16340,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -17233,7 +17216,7 @@ snapshots: '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 - zod: 4.4.3 + zod: 4.6.5 '@angular/platform-browser-dynamic@20.3.33(@angular/common@20.3.33(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.33)(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.33(@angular/common@20.3.33(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1)))': dependencies: @@ -26843,8 +26826,8 @@ snapshots: '@babel/parser': 7.29.8 eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -31449,10 +31432,10 @@ snapshots: transitivePeerDependencies: - encoding - openai@7.5.0(ws@8.21.1)(zod@3.24.4): + openai@7.5.0(ws@8.21.1)(zod@4.6.5): optionalDependencies: ws: 8.21.1 - zod: 3.24.4 + zod: 4.6.5 opencollective-postinstall@2.0.3: {} @@ -35864,10 +35847,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - zod-to-json-schema@3.24.6(zod@3.24.4): - dependencies: - zod: 3.24.4 - zod-to-json-schema@3.25.2(zod@4.1.13): dependencies: zod: 4.1.13 @@ -35876,11 +35855,9 @@ snapshots: dependencies: zod: 4.4.3 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.5): dependencies: - zod: 4.4.3 - - zod@3.24.4: {} + zod: 4.6.5 zod@3.25.76: {} @@ -35888,6 +35865,8 @@ snapshots: zod@4.4.3: {} + zod@4.6.5: {} + zone.js@0.15.1: {} zone.js@0.16.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 055ae9bbb03a..d006717862cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -76,8 +76,7 @@ catalog: jquery: 4.0.0 stylelint: 16.22.0 testcafe: 3.7.5 - zod: 3.24.4 - zod-to-json-schema: 3.24.6 + zod: ^4.6.5 "@babel/eslint-parser": 7.29.7 "@eslint-stylistic/metadata": ^2.13.0 "@eslint/eslintrc": 3.3.5 From 932a24765e0b7847e696d97e3bd2e03f04146141 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:43:57 +0400 Subject: [PATCH 3/6] test(grid): cover AI assistant JSON schema compat helper --- .../__tests__/response_schema.test.ts | 38 ++++---- .../__tests__/grid_commands.test.ts | 1 - .../ai_assistant/__tests__/utils.test.ts | 88 +++++++++++++++++++ 3 files changed, 108 insertions(+), 19 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts index 8df73ce401ac..3b1cd5c0b409 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands/index'; import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; -import type { JsonSchema, ResponseSchemaBranch } from '@ts/grids/grid_core/ai_assistant/types'; +import type { GridCommand, JsonSchema, ResponseSchemaBranch } from '@ts/grids/grid_core/ai_assistant/types'; import type { InternalGrid } from '@ts/grids/grid_core/types'; import { dataGridCommands } from '../commands/index'; @@ -528,10 +528,11 @@ const expectedArgsByCommand: Record = { }, }; -const buildResponseSchema = (commands: typeof coreCommands): JsonSchema => new GridCommands( - {} as InternalGrid, - commands, -).buildResponseSchema(); +const buildResponseSchema = (commands: GridCommand[]): JsonSchema => { + const gridCommands = new GridCommands({} as InternalGrid, commands); + + return gridCommands.buildResponseSchema(); +}; const getArgsByCommand = (schema: JsonSchema): Record => { const { actions } = schema.properties as { @@ -543,35 +544,36 @@ const getArgsByCommand = (schema: JsonSchema): Record name); + describe('Response schema', () => { describe('DataGrid commands', () => { - it.each(Object.keys(expectedArgsByCommand))('should build args schema for the "%s" command', (commandName) => { - const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); - - const argsByCommand = getArgsByCommand(schema); + const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); + const argsByCommand = getArgsByCommand(schema); + it.each(dataGridCommandNames)('should build args schema for the "%s" command', (commandName) => { expect(argsByCommand[commandName]).toEqual(expectedArgsByCommand[commandName]); }); it('should build args for all commands', () => { - const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); - - const argsByCommand = getArgsByCommand(schema); - - expect(Object.keys(argsByCommand)).toEqual(Object.keys(expectedArgsByCommand)); + expect(Object.keys(argsByCommand)).toEqual(dataGridCommandNames); }); it('should not build root $defs', () => { - const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); - expect(schema.$defs).toBeUndefined(); }); }); describe('TreeList commands', () => { - it('should not build root $defs', () => { - const schema = buildResponseSchema(coreCommands); + const schema = buildResponseSchema(coreCommands); + const argsByCommand = getArgsByCommand(schema); + + it.each(treeListCommandNames)('should build args schema for the "%s" command', (commandName) => { + expect(argsByCommand[commandName]).toEqual(expectedArgsByCommand[commandName]); + }); + it('should not build root $defs', () => { expect(schema.$defs).toBeUndefined(); }); }); 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 35b4d93a84d8..fc8261582205 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 @@ -323,7 +323,6 @@ describe('GridCommands', () => { 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']); }); 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 3242421e49ff..0b56ab10d7a8 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 @@ -8,6 +8,7 @@ import { import type { Message } from '@js/ui/chat'; import { custom } from '@js/ui/dialog'; import * as themes from '@js/ui/themes'; +import type { z } from 'zod'; import { AI_ASSISTANT_AUTHOR_ID, @@ -28,6 +29,7 @@ import { isPopupOptions, isTitleOption, isUserMessage, + makeOpenAICompatible, } from '../utils'; jest.mock('@js/ui/dialog', () => ({ @@ -456,6 +458,92 @@ describe('expandTypeArraysToAnyOf', () => { }); }); +describe('makeOpenAICompatible', () => { + it('lists every object property in required', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'number' } }, + required: ['a'], + additionalProperties: false, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.required).toEqual(['a', 'b']); + }); + + it('does not add required to an object without properties', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: {}, + additionalProperties: false, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + }); + + it('adds additionalProperties false when it is missing', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' } }, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.additionalProperties).toBe(false); + }); + + it('keeps existing additionalProperties', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: { type: 'string' }, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.additionalProperties).toEqual({ type: 'string' }); + }); + + it('removes safe integer bounds', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'integer', + minimum: Number.MIN_SAFE_INTEGER, + maximum: Number.MAX_SAFE_INTEGER, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ type: 'integer' }); + }); + + it('keeps custom integer bounds', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ type: 'integer', minimum: 0 }); + }); + + it('leaves other schemas unchanged', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { type: 'string' }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ type: 'string' }); + }); +}); + describe('hoistSchemaRefs', () => { it('returns empty object when no schemas have $ref', () => { const schema: JsonSchema = { From 68e52bb6246606e4ed361bd8c66c016257a7292d Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:11:31 +0400 Subject: [PATCH 4/6] fix(grid): run zod without eval for CSP --- .../data_grid/ai_assistant/commands/grouping.ts | 2 +- .../data_grid/ai_assistant/commands/summary.ts | 2 +- .../grid_core/ai_assistant/__tests__/zod.test.ts | 16 ++++++++++++++++ .../grid_core/ai_assistant/commands/columns.ts | 2 +- .../grid_core/ai_assistant/commands/filtering.ts | 2 +- .../grid_core/ai_assistant/commands/focus.ts | 2 +- .../grid_core/ai_assistant/commands/paging.ts | 2 +- .../grid_core/ai_assistant/commands/searching.ts | 2 +- .../grid_core/ai_assistant/commands/selection.ts | 2 +- .../grid_core/ai_assistant/commands/sorting.ts | 2 +- .../grid_core/ai_assistant/commands/utils.ts | 2 +- .../grid_core/ai_assistant/grid_commands.ts | 2 +- .../grids/grid_core/ai_assistant/zod.ts | 6 ++++++ .../devextreme/testing/helpers/esm-shims/zod.js | 1 + 14 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts index 9aa2dde21f45..a1b1264ad53a 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts @@ -1,6 +1,6 @@ import { defineGridCommand } from '@ts/grids/grid_core/ai_assistant/commands/defineGridCommand'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from './types'; diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts index 8e85c2d481ec..b0d435b367a3 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts @@ -3,7 +3,7 @@ import { defineGridCommand } from '@ts/grids/grid_core/ai_assistant/commands/def // eslint-disable-next-line spellcheck/spell-checker import { optionalNullish } from '@ts/grids/grid_core/ai_assistant/commands/utils'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from './types'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts new file mode 100644 index 000000000000..438d4a4ddf00 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts @@ -0,0 +1,16 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import { z } from '../zod'; + +describe('zod', () => { + it('should not use eval when a schema is created and parsed', () => { + const functionSpy = jest.spyOn(globalThis, 'Function'); + + z.object({ value: z.string() }).strict().safeParse({ value: 'test' }); + + expect(functionSpy).not.toHaveBeenCalled(); + functionSpy.mockRestore(); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts index fbce06c02290..cd9dd3f55bfe 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; // eslint-disable-next-line spellcheck/spell-checker 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 886cbfe2ff79..2d1383b10db1 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 @@ -5,8 +5,8 @@ import type { import { when } from '@js/core/utils/deferred'; import { isDefined } from '@js/core/utils/type'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { isMultiValueExpr, resolveFilterValue } from './utils'; 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 8ba752fd6073..7c433729a667 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 @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { compositeKeyPairSchema, isKeyShapeValid, normalizeKey } from './utils'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts index 395fde83ec86..df9d6c6712b2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts @@ -1,5 +1,5 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { defineGridCommand } from './defineGridCommand'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts index 957d0b566f3d..ee9fc712121d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts @@ -1,5 +1,5 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { defineGridCommand } from './defineGridCommand'; 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 a2abff569a5b..d36091f2823c 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 @@ -1,7 +1,7 @@ import type { DataSource } from '@js/common/data'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid, RowKey } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts index 2acf3ae5b50a..b9ffae36d516 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; 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 1bf7e64e62fb..5fe51a26aeb6 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 @@ -4,8 +4,8 @@ import type { } from '@js/common/grids'; import { isString } from '@js/core/utils/type'; import { dateUtilsTs } from '@ts/core/utils/date'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { isDateType } from '@ts/grids/grid_core/m_utils'; -import { z } from 'zod'; type RowKey = string | number | Record; 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 8a98a5643ad2..ef29a39a916a 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 @@ -8,7 +8,7 @@ import { DEFAULT_SUCCESS_MESSAGE, EXECUTION_ABORT_MESSAGE, } from '@ts/grids/grid_core/ai_assistant/const'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '../types'; import type { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts new file mode 100644 index 000000000000..ef4a1531266d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +// eslint-disable-next-line spellcheck/spell-checker +z.config({ jitless: true }); + +export { z }; diff --git a/packages/devextreme/testing/helpers/esm-shims/zod.js b/packages/devextreme/testing/helpers/esm-shims/zod.js index c3cef03a49b7..1aef9e725efe 100644 --- a/packages/devextreme/testing/helpers/esm-shims/zod.js +++ b/packages/devextreme/testing/helpers/esm-shims/zod.js @@ -30,6 +30,7 @@ const z = { describe() { return z; }, safeParse() { return { success: true, data: {} }; }, toJSONSchema() { return { type: 'object' }; }, + config() { return {}; }, }; export { z }; From 8131fbdbaac68b9381d6ec3bc53ed4dd1581e9e0 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:31:30 +0400 Subject: [PATCH 5/6] fix(grid): address AI assistant review --- .../__tests__/response_schema.test.ts | 6 + .../error_handling.integration.test.ts | 21 +++ .../ai_assistant/__tests__/utils.test.ts | 120 ------------------ .../ai_assistant/__tests__/zod.test.ts | 7 +- .../ai_assistant/ai_assistant_controller.ts | 13 +- .../grids/grid_core/ai_assistant/utils.ts | 56 +------- 6 files changed, 46 insertions(+), 177 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts index 3b1cd5c0b409..1b5719fc3740 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts @@ -91,6 +91,7 @@ const expectedArgsByCommand: Record = { visibleIndex: { type: 'integer', minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, }, }, required: ['dataField', 'visibleIndex'], @@ -258,6 +259,7 @@ const expectedArgsByCommand: Record = { index: { type: 'integer', minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, }, }, required: ['index'], @@ -309,6 +311,7 @@ const expectedArgsByCommand: Record = { pageIndex: { type: 'integer', minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, }, }, required: ['pageIndex'], @@ -320,6 +323,7 @@ const expectedArgsByCommand: Record = { pageSize: { type: 'integer', minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, }, }, required: ['pageSize'], @@ -368,6 +372,7 @@ const expectedArgsByCommand: Record = { items: { type: 'integer', minimum: 1, + maximum: Number.MAX_SAFE_INTEGER, }, minItems: 1, }, @@ -459,6 +464,7 @@ const expectedArgsByCommand: Record = { { type: 'integer', minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, }, { type: 'null', diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts index 5042659dd6ef..9a79ee92ffa8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts @@ -334,6 +334,27 @@ describe('AI Assistant error handling', () => { }), ]); }); + + it('should fail when buildResponseSchema throws', async () => { + buildResponseSchemaSpy.mockImplementation(() => { + throw new Error('Unsupported schema type'); + }); + + const { model } = await createDataGridWithAiAndPopup(); + + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); + await flushAsync(); + + const messages = await model.loadMessages(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'An unexpected error occurred. Please try again.', + }), + ]); + }); }); describe('request abort', () => { 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 0b56ab10d7a8..78bb3afe6cb1 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 @@ -511,30 +511,6 @@ describe('makeOpenAICompatible', () => { expect(jsonSchema.additionalProperties).toEqual({ type: 'string' }); }); - it('removes safe integer bounds', () => { - const jsonSchema: z.core.JSONSchema.BaseSchema = { - type: 'integer', - minimum: Number.MIN_SAFE_INTEGER, - maximum: Number.MAX_SAFE_INTEGER, - }; - - makeOpenAICompatible({ jsonSchema }); - - expect(jsonSchema).toEqual({ type: 'integer' }); - }); - - it('keeps custom integer bounds', () => { - const jsonSchema: z.core.JSONSchema.BaseSchema = { - type: 'integer', - minimum: 0, - maximum: Number.MAX_SAFE_INTEGER, - }; - - makeOpenAICompatible({ jsonSchema }); - - expect(jsonSchema).toEqual({ type: 'integer', minimum: 0 }); - }); - it('leaves other schemas unchanged', () => { const jsonSchema: z.core.JSONSchema.BaseSchema = { type: 'string' }; @@ -693,102 +669,6 @@ describe('hoistSchemaRefs', () => { }); }); - 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', diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts index 438d4a4ddf00..48c39c0f2f61 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts @@ -1,16 +1,19 @@ import { - describe, expect, it, jest, + afterEach, describe, expect, it, jest, } from '@jest/globals'; import { z } from '../zod'; describe('zod', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should not use eval when a schema is created and parsed', () => { const functionSpy = jest.spyOn(globalThis, 'Function'); z.object({ value: z.string() }).strict().safeParse({ value: 'test' }); expect(functionSpy).not.toHaveBeenCalled(); - functionSpy.mockRestore(); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index ce912b658506..bbd179007b7e 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -9,6 +9,7 @@ import Guid from '@js/core/guid'; import { captionize } from '@js/core/utils/inflector'; import { isFunction, isString } from '@js/core/utils/type'; import type { Message } from '@js/ui/chat'; +import { logger } from '@ts/core/utils/m_console'; import { fromPromise } from '@ts/core/utils/m_deferred'; import { Controller } from '../modules/modules'; @@ -20,6 +21,7 @@ import type { AIMessage, CommandResult, GridCommand, + JsonSchema, } from './types'; import { getMessageStatus, isAIMessage } from './utils'; @@ -176,9 +178,18 @@ export class AIAssistantController extends Controller { }); } + private buildResponseSchema(): JsonSchema | undefined { + try { + return this.gridCommands?.buildResponseSchema(); + } catch (error: unknown) { + logger.error('Error building the AI Assistant response schema:', error); + return undefined; + } + } + private sendRequestToAICore(aiMessage: AIMessage): Promise { return this.withProcessing(new Promise((resolve, reject) => { - const responseSchema = this.gridCommands?.buildResponseSchema(); + const responseSchema = this.buildResponseSchema(); if (!responseSchema) { const localizedErrorMsg = messageLocalization.format('dxDataGrid-aiAssistantUnexpectedErrorMessage'); 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 8252dadd2a45..b5e531ee738d 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 @@ -64,16 +64,6 @@ export const makeOpenAICompatible = ( jsonSchema.additionalProperties ??= false; } - - if (jsonSchema.type === 'integer') { - if (jsonSchema.minimum === Number.MIN_SAFE_INTEGER) { - delete jsonSchema.minimum; - } - - if (jsonSchema.maximum === Number.MAX_SAFE_INTEGER) { - delete jsonSchema.maximum; - } - } }; /** @@ -134,29 +124,6 @@ export const expandTypeArraysToAnyOf = ( 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 currentNode: unknown = schema; - - for (const segment of segments) { - if (!currentNode || typeof currentNode !== 'object') { - return undefined; - } - - currentNode = (currentNode as Record)[segment]; - } - - return currentNode; -}; - /** Recursively collects all `$ref` string values from a schema node. */ const collectAllRefs = (node: unknown, refs: Set): void => { if (!node || typeof node !== 'object') { @@ -212,28 +179,11 @@ const rewriteRefs = ( } }; -/** - * 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. - * * 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. @@ -257,15 +207,13 @@ export const hoistSchemaRefs = ( const refMap = new Map(); for (const ref of refs) { - const baseName = defNameFromRef(ref); + const baseName = ref.slice('#/$defs/'.length); 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); + const resolved = (schema.$defs as JsonSchema | undefined)?.[baseName]; if (resolved && typeof resolved === 'object') { mergedDefs[prefixedName] = JSON.parse( From 9d36edfcaafc62152c2631e3539c3717341c051d Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:31:40 +0400 Subject: [PATCH 6/6] chore(deps): pin zod 4.6.5 --- pnpm-lock.yaml | 2 +- pnpm-workspace.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc597318f641..e4406f7eeaf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,7 +149,7 @@ catalogs: specifier: 10.4.1 version: 10.4.1 zod: - specifier: ^4.6.5 + specifier: 4.6.5 version: 4.6.5 tools: '@types/node': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d006717862cf..35b2d4a0ba69 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -76,7 +76,7 @@ catalog: jquery: 4.0.0 stylelint: 16.22.0 testcafe: 3.7.5 - zod: ^4.6.5 + zod: 4.6.5 "@babel/eslint-parser": 7.29.7 "@eslint-stylistic/metadata": ^2.13.0 "@eslint/eslintrc": 3.3.5