From 6fe269c1bc9e020708bb3b539aaa0fac356effd4 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 09:54:18 +0400 Subject: [PATCH 01/34] install zod and zon-to-json-schema packages --- packages/devextreme/package.json | 7 ++++--- pnpm-lock.yaml | 14 +++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 71d919974a68..18f14cc2489b 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -65,7 +65,8 @@ "jszip": "^3.10.1", "rrule": "^2.7.1", "unplugin": "^3.0.0", - "zod": "~3.24.4" + "zod": "3.24.4", + "zod-to-json-schema": "3.24.6" }, "devDependencies": { "@babel/core": "7.29.0", @@ -73,8 +74,8 @@ "@babel/parser": "7.29.2", "@babel/plugin-proposal-decorators": "7.29.0", "@babel/plugin-transform-modules-commonjs": "7.28.6", - "@babel/plugin-transform-typescript": "7.28.6", "@babel/plugin-transform-runtime": "7.29.0", + "@babel/plugin-transform-typescript": "7.28.6", "@babel/preset-env": "7.29.2", "@devextreme-generator/angular": "3.0.12", "@devextreme-generator/build-helpers": "3.0.12", @@ -214,8 +215,8 @@ "typescript-min": "npm:typescript@4.9.5", "uuid": "14.0.0", "vinyl": "2.2.1", - "vite": "8.0.8", "vinyl-named": "1.1.0", + "vite": "8.0.8", "webpack": "5.105.4", "webpack-stream": "7.0.0", "yaml": "2.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 345327887735..123508311aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1385,8 +1385,11 @@ importers: specifier: ^3.0.0 version: 3.0.0 zod: - specifier: ~3.24.4 + specifier: 3.24.4 version: 3.24.4 + zod-to-json-schema: + specifier: 3.24.6 + version: 3.24.6(zod@3.24.4) devDependencies: '@babel/core': specifier: 7.29.0 @@ -18092,6 +18095,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + 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: @@ -41656,6 +41664,10 @@ snapshots: compress-commons: 6.0.2 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 From 4d96e9a464b23025c0d7b3bed9938806f1a01b86 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 09:55:00 +0400 Subject: [PATCH 02/34] add necessary types for grid commands --- .../__internal/grids/grid_core/ai_assistant/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 2428ddeed0aa..48a54416e884 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 @@ -38,3 +38,13 @@ export interface CommandResponse { } export type CommandResults = CommandResult[]; + +export interface CommandMessages { + success: string; + failure: string; +} + +export type CustomizeResponseText = ( + commandName: string, + commandArgs: Record, +) => Partial | undefined; From 8f7dbedb053526f457a87678c49cd56cdc6c271e Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 11:24:53 +0400 Subject: [PATCH 03/34] temporarily add grid_commands_spec.md to observe all the changes --- .../ai_assistant/grid_commands_spec.md | 736 ++++++++++++++++++ 1 file changed, 736 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md new file mode 100644 index 000000000000..9097a517c728 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md @@ -0,0 +1,736 @@ +# GridCommands Specification + +## Overview + +`GridCommands` is a utility class that bridges AI responses and DataGrid API calls. It maintains a registry of command descriptors, builds a unified JSON Schema (draft-07) for AI response structure, validates responses, executes commands sequentially (awaiting async ones), and returns user-facing result messages. + +## Dependencies + +- **zod** `3.24.4` — used to define per-command `args` schemas in TypeScript and to validate AI responses at runtime +- **zod-to-json-schema** `3.24.6` — converts Zod schemas to JSON Schema draft-07 for `buildResponseSchema()` output sent to the LLM + +Each `GridCommand.schema` is defined as a `ZodObject` instead of a raw `JsonSchema`. `buildResponseSchema()` converts registered Zod schemas to JSON Schema via `zodToJsonSchema`. `validateResponse()` uses Zod's `.safeParse()` for per-command arg validation, giving structured error details internally. + +## Flow + +1. `GridCommands.buildResponseSchema()` — merges per-command schemas into unified JSON Schema draft-07 +2. `AIAssistantIntegrationController.buildContext()` — collects current grid state for the AI prompt (uses `this.component`) +3. AI returns `ExecuteGridAssistantCommandResult` (`{ actions: [{ name, args }] }`) +4. `GridCommands.validateResponse(response)` — structural validation against merged schema; any mismatch → fail entirely +5. `GridCommands.executeActions(actions, customizeResponseText?)` — runs commands in AI-returned order, awaiting each before next; returns `CommandResult[]` used directly to render response + +## File Structure + +``` +ai_assistant/ + grid_commands.ts # GridCommands class + types.ts # GridCommand interface, CommandResult, shared types + commands/ + sorting.ts # sorting, clearSorting + filtering.ts # filterValue, clearFilter, searching + grouping.ts # grouping + paging.ts # page, pageSize, groupPaging + selection.ts # selectByKeys, selectByIndexes, selectAll, deselectAll, clearSelection + columns.ts # columnsVisibility, columnsReorder, columnsPinning, columnsResize + summary.ts # summary, clearSummary + focus.ts # rowFocusing + __tests__/ + grid_commands.test.ts # GridCommands class tests + commands/ + sorting.test.ts + filtering.test.ts + grouping.test.ts + paging.test.ts + selection.test.ts + columns.test.ts + summary.test.ts + focus.test.ts +``` + +--- + +## Types (`types.ts`) + +```typescript +import { z, ZodObject } from 'zod'; + +type JsonSchema = Record; + +type CommandStatus = 'success' | 'failure' | 'aborted'; + +interface CommandResult { + status: CommandStatus; + message: string; +} + +interface CommandCallbacks { + success(message?: string): CommandResult; + failure(message?: string): CommandResult; +} + +type CommandExecutor = (args: Record) => Promise; + +interface GridCommand { + name: string; + description: string; // Human-readable command purpose, used as branch-level description in schema + schema: ZodObject; // Zod schema defining the `args` shape; converted to JSON Schema by buildResponseSchema(), used by validateResponse() via .safeParse() + execute( + component: InternalGrid, + callbacks: CommandCallbacks, + ): CommandExecutor; +} +``` + +### Execute pattern + +`execute` is a factory: it receives `component` and `callbacks`, returns a `CommandExecutor` function that takes `args` and returns `Promise`. The `callbacks.success()` and `callbacks.failure()` helpers are provided by `GridCommands` and create `CommandResult` with the corresponding status and a custom or default message. + +```typescript +// Example command execute: +execute: (component, { success, failure }) => async (args) => { + try { + await Promise.resolve(component.option(args.newOptionValue)); + return success('Sorting applied'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return failure(`Failed to apply sorting: ${message}`); + } +} +``` + +--- + +## GridCommands Class (`grid_commands.ts`) + +### Constructor + +```typescript +constructor(component: InternalGrid, commands: GridCommand[]) +``` + +**Acceptance criteria:** +- [ ] Stores `component` for use by `executeActions` +- [ ] Stores commands in an internal registry indexed by `name` +- [ ] Throws if duplicate command names are provided +- [ ] Accepts an empty commands array (no commands registered) + +### Helper methods (passed to command execute as `CommandCallbacks`) + +#### `success(message?: string): CommandResult` + +Returns `{ status: 'success', message: message ?? defaultSuccessMessage }`. + +#### `failure(message?: string): CommandResult` + +Returns `{ status: 'failure', message: message ?? defaultFailureMessage }`. + +**Acceptance criteria:** +- [ ] `success()` without argument returns `CommandResult` with `status: 'success'` and a default message +- [ ] `success('Custom msg')` returns `CommandResult` with `status: 'success'` and `message: 'Custom msg'` +- [ ] `failure()` without argument returns `CommandResult` with `status: 'failure'` and a default message +- [ ] `failure('Custom msg')` returns `CommandResult` with `status: 'failure'` and `message: 'Custom msg'` + +### `abort(): void` + +Sets an internal `_aborted` flag to `true`. When `executeActions` is running, it checks this flag before each command iteration. If set, execution stops and returns partial results with an `aborted` entry for the first skipped command. Calling `abort()` when not executing still sets the flag, but the flag is only reset when `executeActions` actually begins execution (i.e., passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset `_aborted`. + +**Acceptance criteria:** +- [ ] Sets `_aborted` to `true` +- [ ] Idempotent — calling multiple times has no additional effect +- [ ] Calling when not executing sets the flag; the flag persists until the next successful `executeActions` start + +### `isExecuting(): boolean` + +Returns the current value of the internal `_executing` flag (the same flag used by the reentrancy guard). + +**Acceptance criteria:** +- [ ] Returns `true` while `executeActions` is in progress +- [ ] Returns `false` before `executeActions` is called +- [ ] Returns `false` after `executeActions` completes (normally, via abort, or via reentrancy rejection) + +### JSON Schema LLM Constraints + +Not all JSON Schema draft-07 features are supported by LLMs. The following constraints apply when building schemas: + +**Not supported (do not use):** +- `oneOf`, `anyOf` on **root** level; `allOf` at any level +- `not` (ignored by LLMs) +- `pattern`, regex-based validation +- `dependencies` +- `if` / `then` / `else` + +**Allowed on non-root level:** +- `anyOf` — used inside `items` to bind each command name to its specific args schema + +**Partially supported (always add a `description` when using):** +- `format` +- `minimum` / `maximum` +- `examples` + +**Required:** +- `additionalProperties` must always be set to `false` + +### `buildResponseSchema(): JsonSchema` + +Generates a unified JSON Schema draft-07 using `anyOf` inside `items` to bind each command name to its specific args schema. Each `anyOf` branch is a complete `{name, args}` object with a branch-level `description`. Each `GridCommand.schema` defines the full `args` object schema including `required`, `properties`, and `additionalProperties: false`. + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["actions"], + "additionalProperties": false, + "properties": { + "actions": { + "type": "array", + "description": "List of grid commands to execute", + "items": { + "anyOf": [ + { + "type": "object", + "description": "Apply sorting to one or more columns", + "required": ["name", "args"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "enum": ["sorting"] + }, + "args": + } + }, + { + "type": "object", + "description": "Remove all sorting", + "required": ["name", "args"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "enum": ["clearSorting"] + }, + "args": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + } + } + ] + } + } + } +} +``` + +No-arg commands (e.g. `clearSorting`, `selectAll`) use `args: { type: "object", additionalProperties: false, properties: {} }`. + +**Acceptance criteria:** +- [ ] Returns valid JSON Schema draft-07 object +- [ ] `actions.items` uses `anyOf` with one branch per registered command +- [ ] Each branch has a `description` with the command's purpose (from `GridCommand.description`) +- [ ] Each branch has `name.enum` with exactly one command name +- [ ] Each branch has `args` with that command's own schema (including `required` and `additionalProperties: false`) +- [ ] No `anyOf` at root schema level +- [ ] No use of `allOf`, `if/then/else`, `not`, `pattern`, `dependencies` +- [ ] `additionalProperties: false` is set on every object level +- [ ] Schema changes when commands are added/removed from the registry +- [ ] With no commands registered, `anyOf` is an empty array +- [ ] No-arg commands have `args: { type: "object", additionalProperties: false, properties: {} }` + +### `validateResponse(response: ExecuteGridAssistantCommandResult): boolean` + +Validates the AI response against the per-command schemas defined in each `GridCommand.schema`. Since each command has its own `anyOf` branch with explicit `required` and `additionalProperties: false`, validation checks each action's `args` against the matching command's schema. + +- `response.actions` must be an array +- Each action must have `name` (string, known command) and `args` (object, not `null`) +- Each action's `args` is validated against the matching command's `schema` (which defines its own `required` properties, allowed property types, and `additionalProperties: false`) +- Extra/unknown properties in `args` are rejected (enforced by per-command `additionalProperties: false`) +- `null` values: if `args` is `null` instead of an object, validation fails (inconsistent with schema) +- Empty string `name` (`""`) is treated as an unknown command — validation fails +- Any mismatch → return `false` (entire response rejected) + +**Acceptance criteria:** +- [ ] Returns `true` for a valid response with known command names and correct arg types +- [ ] Returns `false` if `response.actions` is not an array +- [ ] Returns `false` if `response.actions` is missing +- [ ] Returns `false` if any action has an unknown `name` +- [ ] Returns `false` if any action's `name` is not a string (e.g. `name: 123`) +- [ ] Returns `false` if any action's `name` is an empty string (`""`) +- [ ] Returns `false` if any action is missing `name` or `args` +- [ ] Returns `false` if any action's `args` is `null` (must be an object) +- [ ] Returns `false` if any action's `args` has wrong types for required properties +- [ ] Returns `false` if any action's `args` is missing required properties for that command (as defined by command's `schema.required`) +- [ ] Returns `false` if any action's `args` contains extra properties not in that command's schema +- [ ] Returns `true` for an empty `actions` array +- [ ] Returns `true` for no-arg commands when `args` is `{}` +- [ ] Rejects the entire response on first mismatch + +### `async executeActions(actions, customizeResponseText?): Promise` + +**Precondition:** `validateResponse` must be called before `executeActions`. If the response is invalid, `executeActions` should not be called. However, as a defensive measure, if an unknown command name is encountered during execution, it records a `failure` result for that action and continues to the next. + +**Reentrancy guard:** `executeActions` tracks whether it is currently executing via an internal `_executing` flag. If called while a previous execution is still in progress, it throws an error immediately (does not queue or execute). This makes the programming error explicit and impossible to silently ignore. + +**Abort support:** `_aborted` is reset to `false` only when `executeActions` successfully starts (passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset the flag — so `abort()` called during an in-progress execution is never lost. Before each loop iteration, the method checks `_aborted`. If `true`, it pushes a `CommandResult` with `status: 'aborted'` and a default message (e.g. `'Command execution aborted'`) for the first skipped command, then breaks. The method returns the partial `CommandResult[]` containing results for already-completed commands plus one `aborted` entry. Remaining actions are not represented in results. After abort-induced exit, `_executing` is set to `false` so subsequent calls work normally. + +- Uses `this.component` (no `component` parameter) +- Resets `_aborted = false` only on actual execution start (not on reentrancy rejection) +- Iterates actions in AI-returned order +- Before each iteration, checks `_aborted`; if `true`, pushes `{ status: 'aborted', message: defaultAbortedMessage }` and breaks +- For each action: finds matching `GridCommand` by `name`, calls `execute(this.component, { success, failure })` to get executor, then calls `executor(args)` +- If command name is unknown (defensive), records `failure('Unknown command: ')` and continues +- Awaits each command before proceeding to next +- If executor throws, catches and records `failure()` +- If `customizeResponseText` is provided, applies message override per command after execution (see Message Customization section below) +- Returns `CommandResult[]` on success or abort; throws if called concurrently + +**Acceptance criteria:** +- [ ] Uses `this.component` (no `component` parameter) +- [ ] Resets `_aborted` to `false` only on actual execution start (not on reentrancy rejection) +- [ ] Executes commands in the order provided in `actions` array +- [ ] Each command is awaited before the next one starts +- [ ] Returns one `CommandResult` per executed action (plus one `aborted` entry if aborted) +- [ ] A throwing executor produces `CommandResult` with `status: 'failure'` and the error message +- [ ] An async executor that rejects produces `CommandResult` with `status: 'failure'` +- [ ] Unknown command name (defensive) produces `CommandResult` with `status: 'failure'` and message containing the command name +- [ ] Returns empty array for empty `actions` +- [ ] If called while another `executeActions` is in progress, throws an error +- [ ] After the first call completes, subsequent calls work normally +- [ ] Commands that succeed have `status: 'success'`; commands that fail have `status: 'failure'` +- [ ] `abort()` called during execution → results contain completed commands + one `{ status: 'aborted' }` entry, then stops +- [ ] `abort()` called before first command executes → returns `[{ status: 'aborted', message: ... }]` +- [ ] `_aborted` is reset only on actual execution start, so a previous `abort()` does not affect the next successful call +- [ ] A concurrent call rejected by reentrancy guard does **not** reset `_aborted` +- [ ] `_executing` is set to `false` after abort-induced exit +- [ ] Only one `aborted` result is added (for the first skipped command); remaining actions are not represented +- [ ] Without `customizeResponseText`, all messages are defaults from command executors +- [ ] `customizeResponseText` is called once per executed action with correct `commandName` and `commandArgs` +- [ ] `customizeResponseText` returning `{ success: 'X', failure: 'Y' }` replaces the message for the matching status +- [ ] `customizeResponseText` returning `{ success: 'X' }` only replaces message when status is `'success'`; `'failure'` stays default +- [ ] `customizeResponseText` returning `undefined` leaves the default message unchanged +- [ ] `customizeResponseText` is not called for the `aborted` entry or for actions skipped by abort +- [ ] `customizeResponseText` is not called for actions that were not executed (e.g. validation failed) + +### Message Customization + +Users can provide a `customizeResponseText` callback to override default success/failure messages per command. + +#### Type + +```typescript +type CommandMessages = { + success: string; + failure: string; +}; + +type CustomizeResponseText = ( + commandName: string, + commandArgs: Record, +) => Partial | undefined; +``` + +#### Usage + +`customizeResponseText` is passed to `GridCommands` (e.g. via constructor or `executeActions` options). For each executed command, if provided, it is called with the command name and args. The callback can: + +- Return `{ success, failure }` — overrides both messages +- Return `{ success }` or `{ failure }` — overrides only specified, keeps default for the other +- Return `undefined` — uses default messages + +> **Note:** `customizeResponseText` can override any message, including diagnostic failure messages set by the command executor (e.g. `'Column "foo" does not exist'`). This is by design — the consumer takes full responsibility for the content of overridden messages. If preserving diagnostic detail is important, the callback should return `undefined` for commands whose failure messages should not be altered. + +#### Example + +```typescript +customizeResponseText: (commandName, commandArgs) => { + switch (commandName) { + case 'filtering': + return { + success: `Successfully filtered ${commandArgs.dataField}`, + failure: `Failed to filter ${commandArgs.dataField}`, + }; + case 'sorting': { + return { + success: `Successfully sorted ${commandArgs.dataField}`, + }; + } + default: + return undefined; + } +} +``` + +#### Integration in `executeActions` + +```typescript +// Inside GridCommands.executeActions: +for (const { name, args } of actions) { + if (this._aborted) { + results.push({ status: 'aborted', message: defaultAbortedMessage }); + break; + } + + const executor = command.execute(this.component, callbacks); + const result = await executor(args); + + // Apply message customization + const customMessages = customizeResponseText?.(name, args); + + if (isDefined(customMessages?.[result.status])) { + result.message = customMessages[result.status]; + } + + results.push(result); +} +``` + +--- + +## Command Specifications + +### Sorting (`sorting.ts`) + +#### `sorting` + +- **Description:** Apply sorting to one or more columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, 'sortOrder', sortOrder)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +#### `clearSorting` + +- **Description:** Remove all sorting +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(i, 'sortOrder', undefined)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Filtering (`filtering.ts`) + +#### `filterValue` + +- **Description:** Apply a filter expression +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `component.option('filterValue', value)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `clearFilter` + +- **Description:** Clear all filters +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `dataController.clearFilter()` +- **Success message:** TODO +- **Failure message:** TODO + +#### `searching` + +- **Description:** Set search panel text +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `component.option('searchPanel.text', value)` +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Grouping (`grouping.ts`) + +#### `grouping` + +- **Description:** Group by one or more columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, 'groupIndex', index)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Paging (`paging.ts`) + +#### `page` + +- **Description:** Navigate to a specific page +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `dataController.pageIndex(index)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `pageSize` + +- **Description:** Change the page size +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `dataController.pageSize(size)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `groupPaging` + +- **Description:** Navigate group paging +- **Args schema:** + ```json + TODO + ``` +- **Execute:** TODO +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Selection (`selection.ts`) + +#### `selectByKeys` + +- **Description:** Select rows by key values +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `selectionController.selectRows(keys, preserve)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `selectByIndexes` + +- **Description:** Select rows by row indexes +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `selectionController.selectRowsByIndexes(indexes)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `selectAll` + +- **Description:** Select all rows +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `selectionController.selectAll()` +- **Success message:** TODO +- **Failure message:** TODO + +#### `deselectAll` + +- **Description:** Deselect all rows +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `selectionController.deselectAll()` +- **Success message:** TODO +- **Failure message:** TODO + +#### `clearSelection` + +- **Description:** Clear selection +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `selectionController.clearSelection()` +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Columns (`columns.ts`) + +#### `columnsVisibility` + +- **Description:** Show or hide columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, 'visible', value)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +#### `columnsReorder` + +- **Description:** Reorder columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, 'visibleIndex', index)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +#### `columnsPinning` + +- **Description:** Pin/unpin columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, { fixed, fixedPosition })` for each column +- **Success message:** TODO +- **Failure message:** TODO + +#### `columnsResize` + +- **Description:** Resize columns +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `columnsController.columnOption(dataField, 'width', value)` for each column +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Summary (`summary.ts`) + +#### `summary` + +- **Description:** Configure summary items +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `component.option('summary', value)` +- **Success message:** TODO +- **Failure message:** TODO + +#### `clearSummary` + +- **Description:** Remove all summary items +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `component.option('summary', {})` +- **Success message:** TODO +- **Failure message:** TODO + +--- + +### Focus (`focus.ts`) + +#### `rowFocusing` + +- **Description:** Focus a specific row +- **Args schema:** + ```json + TODO + ``` +- **Execute:** `component.option('focusedRowKey', key)` + `focusController.navigateToRow(key)` +- **Success message:** TODO +- **Failure message:** TODO + +--- + +## Integration with AIAssistantIntegrationController + ```json + TODO + ``` + +In `m_ai_assistant_integration_controller.ts`: + +1. Instantiate `GridCommands` with `this.component` and full command list +2. `buildContext()` — implemented directly on the controller (not delegated to `gridCommands`) +3. `buildResponseSchema()` → `gridCommands.buildResponseSchema()` +4. In `onComplete` callback: + - `gridCommands.validateResponse(response)` → if `false`, return failure text + - `const results = await gridCommands.executeActions(response.actions, customizeResponseText)` + - Use `results` directly to render response message in chat (handle `aborted` status entries appropriately) +5. In `abortRequest()`: + - Call existing `this.abort?.()` to abort LLM request + - Call `this.gridCommands?.abort()` to abort in-progress command execution +6. Add `isExecutingCommands()` method: + - Returns `this.gridCommands?.isExecuting ?? false` +7. Wire popup `onHidden` to call `abortRequest()` so closing the chat aborts both LLM and command execution + +### `buildContext(): Record` + +Implemented on `AIAssistantIntegrationController`, not on `GridCommands`. The controller already owns `this.component` and is the orchestration layer between the grid and the AI. This keeps `GridCommands` focused on schema building, validation, and execution. + +Collects current grid state from `this.component`: + +- **columns** — all columns (including hidden) with: `dataField`, `caption`, `dataType`, `visible`, `sortOrder`, `sortIndex`, `groupIndex`, `filterValue`, `fixed`, `fixedPosition`, `width`, `visibleIndex` +- **filtering** — current `filterValue`, `filterPanel` state +- **paging** — `pageIndex`, `pageSize`, `totalCount` +- **search** — current search text +- **selection** — selected keys +- **summary** — current summary configuration + +**Acceptance criteria:** +- [ ] Uses `this.component` (no `component` parameter) +- [ ] Returns an object containing all listed state categories +- [ ] `columns` includes all columns (both visible and hidden) with their `visible` flag +- [ ] `columns` includes all listed properties for each column +- [ ] `paging` reflects current `pageIndex`, `pageSize`, and `totalCount` +- [ ] `search` reflects current search panel text (empty string if none) +- [ ] `selection` reflects currently selected keys (empty array if none) +- [ ] `summary` reflects current summary configuration (empty if none) +- [ ] Context updates correctly after grid state changes + +--- + +## Out of Scope (Future Iterations) + +The following items are intentionally deferred and should be addressed when custom (user-defined) commands are supported: + +For next iteration, we might consider: +- **Structured validation errors:** `validateResponse` currently returns a bare `boolean`. For production debugging, a structured result like `{ valid: boolean; errors: ValidationError[] }` would help explain *why* validation failed. Acceptable for v1 since the controller shows a generic failure message. +- **Schema versioning:** No `schemaVersion` field or graceful degradation for unknown commands. If commands are added/removed between versions and the LLM provider caches tool definitions, stale schemas will hard-fail at validation. Low risk for v1 since `buildResponseSchema()` is called dynamically per request. +- **Mid-command abort:** Abort is only checked between commands. If a single command executor is long-running (e.g. `selectAll` on a large dataset), it cannot be interrupted mid-execution. A future iteration could pass an `AbortSignal` to `CommandExecutor` so individual commands can cooperatively check for cancellation. +- **Command executor return shape validation:** Currently, all built-in command executors are guaranteed to return a valid `CommandResult` (`{ status, message }`). When custom commands are allowed, `executeActions` should defensively validate the executor's return value. We must agree how to treat malformed results. +- **Per-command timeouts:** No timeout mechanism for individual command executors. A misbehaving executor could hang indefinitely. +- **Actions array length limit:** No cap on the number of actions in a response. +- **No input sanitization on args.** LLM-generated args are passed directly to `component.option()` and `columnOption()`. If `dataField` contains a crafted value, could it access unintended columns or trigger injection? The spec should note that args must be validated against actual grid state (e.g., `dataField` must match an existing column). + +We consider these as excessive: +- **No rollback on partial failure.** If action 3 of 5 fails, actions 1–2 have already mutated the grid. There's no mention of whether this is acceptable or whether a transaction/rollback mechanism is needed. +- **No allowlist for option paths.** `component.option('searchPanel.text', value)` uses a string path — if this pattern is generalized, an LLM could set arbitrary options. + +--- + +## Implementation Order + +1. `types.ts` — interfaces +2. `grid_commands.ts` — class skeleton with `buildResponseSchema`, `validateResponse`, `executeActions` +3. Tests for `GridCommands` class +4. Commands one by one (each includes schema + execute + tests): + 1. `sorting.ts` (sorting, clearSorting) + 2. `filtering.ts` (filterValue, clearFilter, searching) + 3. `grouping.ts` + 4. `paging.ts` (page, pageSize, groupPaging) + 5. `selection.ts` (selectByKeys, selectByIndexes, selectAll, deselectAll, clearSelection) + 6. `columns.ts` (columnsVisibility, columnsReorder, columnsPinning, columnsResize) + 7. `summary.ts` (summary, clearSummary) + 8. `focus.ts` (rowFocusing) +5. Wire into `AIAssistantIntegrationController` +6. Integration tests covering end-to-end flow with mocked AI responses From d217f3b450a520db51445187626ad19ea44377c1 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 11:30:05 +0400 Subject: [PATCH 04/34] add default error message to locales --- packages/devextreme/js/localization/messages/ar.json | 1 + packages/devextreme/js/localization/messages/bg.json | 1 + packages/devextreme/js/localization/messages/ca.json | 1 + packages/devextreme/js/localization/messages/cs.json | 1 + packages/devextreme/js/localization/messages/da.json | 1 + packages/devextreme/js/localization/messages/de.json | 1 + packages/devextreme/js/localization/messages/el.json | 1 + packages/devextreme/js/localization/messages/en.json | 1 + packages/devextreme/js/localization/messages/es.json | 1 + packages/devextreme/js/localization/messages/fa.json | 1 + packages/devextreme/js/localization/messages/fi.json | 1 + packages/devextreme/js/localization/messages/fr.json | 1 + packages/devextreme/js/localization/messages/hu.json | 1 + packages/devextreme/js/localization/messages/it.json | 1 + packages/devextreme/js/localization/messages/ja.json | 1 + packages/devextreme/js/localization/messages/ko.json | 1 + packages/devextreme/js/localization/messages/lt.json | 1 + packages/devextreme/js/localization/messages/lv.json | 1 + packages/devextreme/js/localization/messages/nb.json | 1 + packages/devextreme/js/localization/messages/nl.json | 1 + packages/devextreme/js/localization/messages/pl.json | 1 + packages/devextreme/js/localization/messages/pt.json | 1 + packages/devextreme/js/localization/messages/ro.json | 1 + packages/devextreme/js/localization/messages/ru.json | 1 + packages/devextreme/js/localization/messages/sl.json | 1 + packages/devextreme/js/localization/messages/sv.json | 1 + packages/devextreme/js/localization/messages/tr.json | 1 + packages/devextreme/js/localization/messages/uk.json | 1 + packages/devextreme/js/localization/messages/vi.json | 1 + packages/devextreme/js/localization/messages/zh-tw.json | 1 + packages/devextreme/js/localization/messages/zh.json | 1 + 31 files changed, 31 insertions(+) diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 3240385f5eab..33d11857c5cb 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index 7e5597e098fd..f68df2524145 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index 08a57f52b394..f446f3a5c8c2 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index eb6be4704c96..785f58af7c52 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index c90e8546079a..69bdd337733a 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index 03e48fb2f97f..3fee48a0b31e 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index 61ed1feaa5ec..6032fd55ca08 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index b6b383a02d2f..434bdd5afbdc 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index 74a04007ac6c..f747e26c4147 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index b9477c8a7480..9bae8be77e20 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index 79f9662a525a..8ca811499b7a 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index c2e0ba65e8f5..0f3428c8fac5 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index c19ab61015f1..22d69250cb57 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index 833f187198c4..4063c932735e 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index 9a3236c16f1b..c4aa61430d1c 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ko.json b/packages/devextreme/js/localization/messages/ko.json index bef4d83e95e5..6473191e40f6 100644 --- a/packages/devextreme/js/localization/messages/ko.json +++ b/packages/devextreme/js/localization/messages/ko.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index 4ba5e5e5f9a8..3637384eefb8 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index 2f55b92bab0e..15f6e62a00f2 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 7adda3e3760b..316bbef01b1b 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index fed6f5f6ce61..ce80c7ac970f 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 0a02e8fcd5fe..63896daf7815 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index d56a1088d875..8b00e699b848 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index cfd91f738fba..dd0ccd4c2743 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index 58b41749ee3c..89ef4f5cd1e9 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index 7829e68d5656..8bced9445b7c 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index e5e129c681b5..f30460b44132 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index 0575de043e38..601560583ed7 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index 59400ae3a626..2ffcc25a5c38 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index faa69101d991..32ee91c0deac 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index de9b789430ea..03862856cdc1 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index 4721df8bfafb..fc5904584b81 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -112,6 +112,7 @@ "dxDataGrid-aiAssistantProcessingMessage": "Processing...", "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", + "dxDataGrid-aiAssistantErrorMessage": "Error", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", From 2cc239a78244651540e4a30f408029d41e3c0a25 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 11:37:32 +0400 Subject: [PATCH 05/34] fix spec types description --- .../ai_assistant/grid_commands_spec.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md index 9097a517c728..4b416c989f15 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md @@ -52,9 +52,13 @@ ai_assistant/ ## Types (`types.ts`) ```typescript -import { z, ZodObject } from 'zod'; +import type { ZodObject, ZodRawShape } from 'zod'; +import type { JsonSchema7Type } from 'zod-to-json-schema'; -type JsonSchema = Record; +/** JSON Schema draft-07 object sent to the LLM. */ +type JsonSchema = JsonSchema7Type & { + $schema?: string; +}; type CommandStatus = 'success' | 'failure' | 'aborted'; @@ -64,8 +68,8 @@ interface CommandResult { } interface CommandCallbacks { - success(message?: string): CommandResult; - failure(message?: string): CommandResult; + success: (message?: string) => CommandResult; + failure: (message?: string) => CommandResult; } type CommandExecutor = (args: Record) => Promise; @@ -73,11 +77,11 @@ type CommandExecutor = (args: Record) => Promise interface GridCommand { name: string; description: string; // Human-readable command purpose, used as branch-level description in schema - schema: ZodObject; // Zod schema defining the `args` shape; converted to JSON Schema by buildResponseSchema(), used by validateResponse() via .safeParse() - execute( + schema: ZodObject; // Zod schema defining the `args` shape; converted to JSON Schema by buildResponseSchema(), used by validateResponse() via .safeParse() + execute: ( component: InternalGrid, callbacks: CommandCallbacks, - ): CommandExecutor; + ) => CommandExecutor; } ``` From 2354bf3c61c520899c3f0b52a8357c01ad83b369 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 11:38:03 +0400 Subject: [PATCH 06/34] clarify JsonSchema type --- .../js/__internal/grids/grid_core/ai_assistant/types.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 48a54416e884..9a0dc1afc5a2 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 @@ -1,7 +1,13 @@ import type { InternalGrid } from '@ts/grids/grid_core/m_types'; import type { ZodObject, ZodRawShape } from 'zod'; +import type { JsonSchema7Type } from 'zod-to-json-schema'; -type CommandStatus = 'success' | 'failure' | 'aborted'; +/** JSON Schema draft-07 object sent to the LLM. */ +export type JsonSchema = JsonSchema7Type & { + $schema?: string; +}; + +export type CommandStatus = 'success' | 'failure' | 'aborted'; export interface CommandResult { status: CommandStatus; From a560f0841224bccb9649f0a993be1e6a0afcf2c3 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 13:55:39 +0400 Subject: [PATCH 07/34] fix naming --- .../ai_assistant/grid_commands_spec.md | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md index 4b416c989f15..6a9b80d79c2f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md @@ -17,7 +17,7 @@ Each `GridCommand.schema` is defined as a `ZodObject` instead of a raw `JsonSche 2. `AIAssistantIntegrationController.buildContext()` — collects current grid state for the AI prompt (uses `this.component`) 3. AI returns `ExecuteGridAssistantCommandResult` (`{ actions: [{ name, args }] }`) 4. `GridCommands.validateResponse(response)` — structural validation against merged schema; any mismatch → fail entirely -5. `GridCommands.executeActions(actions, customizeResponseText?)` — runs commands in AI-returned order, awaiting each before next; returns `CommandResult[]` used directly to render response +5. `GridCommands.executeCommands(actions, customizeResponseText?)` — runs commands in AI-returned order, awaiting each before next; returns `CommandResult[]` used directly to render response ## File Structure @@ -113,7 +113,7 @@ constructor(component: InternalGrid, commands: GridCommand[]) ``` **Acceptance criteria:** -- [ ] Stores `component` for use by `executeActions` +- [ ] Stores `component` for use by `executeCommands` - [ ] Stores commands in an internal registry indexed by `name` - [ ] Throws if duplicate command names are provided - [ ] Accepts an empty commands array (no commands registered) @@ -136,21 +136,21 @@ Returns `{ status: 'failure', message: message ?? defaultFailureMessage }`. ### `abort(): void` -Sets an internal `_aborted` flag to `true`. When `executeActions` is running, it checks this flag before each command iteration. If set, execution stops and returns partial results with an `aborted` entry for the first skipped command. Calling `abort()` when not executing still sets the flag, but the flag is only reset when `executeActions` actually begins execution (i.e., passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset `_aborted`. +Sets an internal `_aborted` flag to `true`. When `executeCommands` is running, it checks this flag before each command iteration. If set, execution stops and returns partial results with an `aborted` entry for the first skipped command. Calling `abort()` when not executing still sets the flag, but the flag is only reset when `executeCommands` actually begins execution (i.e., passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset `_aborted`. **Acceptance criteria:** - [ ] Sets `_aborted` to `true` - [ ] Idempotent — calling multiple times has no additional effect -- [ ] Calling when not executing sets the flag; the flag persists until the next successful `executeActions` start +- [ ] Calling when not executing sets the flag; the flag persists until the next successful `executeCommands` start ### `isExecuting(): boolean` Returns the current value of the internal `_executing` flag (the same flag used by the reentrancy guard). **Acceptance criteria:** -- [ ] Returns `true` while `executeActions` is in progress -- [ ] Returns `false` before `executeActions` is called -- [ ] Returns `false` after `executeActions` completes (normally, via abort, or via reentrancy rejection) +- [ ] Returns `true` while `executeCommands` is in progress +- [ ] Returns `false` before `executeCommands` is called +- [ ] Returns `false` after `executeCommands` completes (normally, via abort, or via reentrancy rejection) ### JSON Schema LLM Constraints @@ -270,19 +270,19 @@ Validates the AI response against the per-command schemas defined in each `GridC - [ ] Returns `true` for no-arg commands when `args` is `{}` - [ ] Rejects the entire response on first mismatch -### `async executeActions(actions, customizeResponseText?): Promise` +### `async executeCommands(commands, customizeResponseText?): Promise` -**Precondition:** `validateResponse` must be called before `executeActions`. If the response is invalid, `executeActions` should not be called. However, as a defensive measure, if an unknown command name is encountered during execution, it records a `failure` result for that action and continues to the next. +**Precondition:** `validateResponse` must be called before `executeCommands`. If the response is invalid, `executeCommands` should not be called. However, as a defensive measure, if an unknown command name is encountered during execution, it records a `failure` result for that command and continues to the next. -**Reentrancy guard:** `executeActions` tracks whether it is currently executing via an internal `_executing` flag. If called while a previous execution is still in progress, it throws an error immediately (does not queue or execute). This makes the programming error explicit and impossible to silently ignore. +**Reentrancy guard:** `executeCommands` tracks whether it is currently executing via an internal `_executing` flag. If called while a previous execution is still in progress, it throws an error immediately (does not queue or execute). This makes the programming error explicit and impossible to silently ignore. -**Abort support:** `_aborted` is reset to `false` only when `executeActions` successfully starts (passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset the flag — so `abort()` called during an in-progress execution is never lost. Before each loop iteration, the method checks `_aborted`. If `true`, it pushes a `CommandResult` with `status: 'aborted'` and a default message (e.g. `'Command execution aborted'`) for the first skipped command, then breaks. The method returns the partial `CommandResult[]` containing results for already-completed commands plus one `aborted` entry. Remaining actions are not represented in results. After abort-induced exit, `_executing` is set to `false` so subsequent calls work normally. +**Abort support:** `_aborted` is reset to `false` only when `executeCommands` successfully starts (passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset the flag — so `abort()` called during an in-progress execution is never lost. Before each loop iteration, the method checks `_aborted`. If `true`, it pushes a `CommandResult` with `status: 'aborted'` and a default message (e.g. `'Command execution aborted'`) for the first skipped command, then breaks. The method returns the partial `CommandResult[]` containing results for already-completed commands plus one `aborted` entry. Remaining commands are not represented in results. After abort-induced exit, `_executing` is set to `false` so subsequent calls work normally. - Uses `this.component` (no `component` parameter) - Resets `_aborted = false` only on actual execution start (not on reentrancy rejection) -- Iterates actions in AI-returned order +- Iterates commands in AI-returned order - Before each iteration, checks `_aborted`; if `true`, pushes `{ status: 'aborted', message: defaultAbortedMessage }` and breaks -- For each action: finds matching `GridCommand` by `name`, calls `execute(this.component, { success, failure })` to get executor, then calls `executor(args)` +- For each command: finds matching `GridCommand` by `name`, calls `execute(this.component, { success, failure })` to get executor, then calls `executor(args)` - If command name is unknown (defensive), records `failure('Unknown command: ')` and continues - Awaits each command before proceeding to next - If executor throws, catches and records `failure()` @@ -292,14 +292,14 @@ Validates the AI response against the per-command schemas defined in each `GridC **Acceptance criteria:** - [ ] Uses `this.component` (no `component` parameter) - [ ] Resets `_aborted` to `false` only on actual execution start (not on reentrancy rejection) -- [ ] Executes commands in the order provided in `actions` array +- [ ] Executes commands in the order provided in `commands` array - [ ] Each command is awaited before the next one starts -- [ ] Returns one `CommandResult` per executed action (plus one `aborted` entry if aborted) +- [ ] Returns one `CommandResult` per executed command (plus one `aborted` entry if aborted) - [ ] A throwing executor produces `CommandResult` with `status: 'failure'` and the error message - [ ] An async executor that rejects produces `CommandResult` with `status: 'failure'` - [ ] Unknown command name (defensive) produces `CommandResult` with `status: 'failure'` and message containing the command name -- [ ] Returns empty array for empty `actions` -- [ ] If called while another `executeActions` is in progress, throws an error +- [ ] Returns empty array for empty `commands` +- [ ] If called while another `executeCommands` is in progress, throws an error - [ ] After the first call completes, subsequent calls work normally - [ ] Commands that succeed have `status: 'success'`; commands that fail have `status: 'failure'` - [ ] `abort()` called during execution → results contain completed commands + one `{ status: 'aborted' }` entry, then stops @@ -307,14 +307,14 @@ Validates the AI response against the per-command schemas defined in each `GridC - [ ] `_aborted` is reset only on actual execution start, so a previous `abort()` does not affect the next successful call - [ ] A concurrent call rejected by reentrancy guard does **not** reset `_aborted` - [ ] `_executing` is set to `false` after abort-induced exit -- [ ] Only one `aborted` result is added (for the first skipped command); remaining actions are not represented +- [ ] Only one `aborted` result is added (for the first skipped command); remaining commands are not represented - [ ] Without `customizeResponseText`, all messages are defaults from command executors -- [ ] `customizeResponseText` is called once per executed action with correct `commandName` and `commandArgs` +- [ ] `customizeResponseText` is called once per executed command with correct `commandName` and `commandArgs` - [ ] `customizeResponseText` returning `{ success: 'X', failure: 'Y' }` replaces the message for the matching status - [ ] `customizeResponseText` returning `{ success: 'X' }` only replaces message when status is `'success'`; `'failure'` stays default - [ ] `customizeResponseText` returning `undefined` leaves the default message unchanged -- [ ] `customizeResponseText` is not called for the `aborted` entry or for actions skipped by abort -- [ ] `customizeResponseText` is not called for actions that were not executed (e.g. validation failed) +- [ ] `customizeResponseText` is not called for the `aborted` entry or for commands skipped by abort +- [ ] `customizeResponseText` is not called for commands that were not executed (e.g. validation failed) ### Message Customization @@ -336,7 +336,7 @@ type CustomizeResponseText = ( #### Usage -`customizeResponseText` is passed to `GridCommands` (e.g. via constructor or `executeActions` options). For each executed command, if provided, it is called with the command name and args. The callback can: +`customizeResponseText` is passed to `GridCommands` (e.g. via constructor or `executeCommands` options). For each executed command, if provided, it is called with the command name and args. The callback can: - Return `{ success, failure }` — overrides both messages - Return `{ success }` or `{ failure }` — overrides only specified, keeps default for the other @@ -365,11 +365,11 @@ customizeResponseText: (commandName, commandArgs) => { } ``` -#### Integration in `executeActions` +#### Integration in `executeCommands` ```typescript -// Inside GridCommands.executeActions: -for (const { name, args } of actions) { +// Inside GridCommands.executeCommands: +for (const { name, args } of commands) { if (this._aborted) { results.push({ status: 'aborted', message: defaultAbortedMessage }); break; @@ -668,7 +668,7 @@ In `m_ai_assistant_integration_controller.ts`: 3. `buildResponseSchema()` → `gridCommands.buildResponseSchema()` 4. In `onComplete` callback: - `gridCommands.validateResponse(response)` → if `false`, return failure text - - `const results = await gridCommands.executeActions(response.actions, customizeResponseText)` + - `const results = await gridCommands.executeCommands(response.actions, customizeResponseText)` - Use `results` directly to render response message in chat (handle `aborted` status entries appropriately) 5. In `abortRequest()`: - Call existing `this.abort?.()` to abort LLM request @@ -711,7 +711,7 @@ For next iteration, we might consider: - **Structured validation errors:** `validateResponse` currently returns a bare `boolean`. For production debugging, a structured result like `{ valid: boolean; errors: ValidationError[] }` would help explain *why* validation failed. Acceptable for v1 since the controller shows a generic failure message. - **Schema versioning:** No `schemaVersion` field or graceful degradation for unknown commands. If commands are added/removed between versions and the LLM provider caches tool definitions, stale schemas will hard-fail at validation. Low risk for v1 since `buildResponseSchema()` is called dynamically per request. - **Mid-command abort:** Abort is only checked between commands. If a single command executor is long-running (e.g. `selectAll` on a large dataset), it cannot be interrupted mid-execution. A future iteration could pass an `AbortSignal` to `CommandExecutor` so individual commands can cooperatively check for cancellation. -- **Command executor return shape validation:** Currently, all built-in command executors are guaranteed to return a valid `CommandResult` (`{ status, message }`). When custom commands are allowed, `executeActions` should defensively validate the executor's return value. We must agree how to treat malformed results. +- **Command executor return shape validation:** Currently, all built-in command executors are guaranteed to return a valid `CommandResult` (`{ status, message }`). When custom commands are allowed, `executeCommands` should defensively validate the executor's return value. We must agree how to treat malformed results. - **Per-command timeouts:** No timeout mechanism for individual command executors. A misbehaving executor could hang indefinitely. - **Actions array length limit:** No cap on the number of actions in a response. - **No input sanitization on args.** LLM-generated args are passed directly to `component.option()` and `columnOption()`. If `dataField` contains a crafted value, could it access unintended columns or trigger injection? The spec should note that args must be validated against actual grid state (e.g., `dataField` must match an existing column). @@ -725,7 +725,7 @@ We consider these as excessive: ## Implementation Order 1. `types.ts` — interfaces -2. `grid_commands.ts` — class skeleton with `buildResponseSchema`, `validateResponse`, `executeActions` +2. `grid_commands.ts` — class skeleton with `buildResponseSchema`, `validateResponse`, `executeCommands` 3. Tests for `GridCommands` class 4. Commands one by one (each includes schema + execute + tests): 1. `sorting.ts` (sorting, clearSorting) From 95659fd828e22083e91dee0c57686e58e3313d45 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Tue, 28 Apr 2026 17:32:52 +0400 Subject: [PATCH 08/34] remove "m_" prefix from AIAssistantIntegrationController --- .../ai_assistant_integration_controller.integration.test.ts | 2 +- ...ion_controller.ts => ai_assistant_integration_controller.ts} | 0 .../grids/grid_core/ai_assistant/grid_commands_spec.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/devextreme/js/__internal/grids/grid_core/ai_assistant/{m_ai_assistant_integration_controller.ts => ai_assistant_integration_controller.ts} (100%) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts index e37dce86bf56..e15adc3d8abc 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -21,7 +21,7 @@ import { beforeTest, createDataGrid, } from '../../__tests__/__mock__/helpers/utils'; -import { AIAssistantIntegrationController } from '../m_ai_assistant_integration_controller'; +import { AIAssistantIntegrationController } from '../ai_assistant_integration_controller'; interface SendRequestResult { promise: Promise; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts similarity index 100% rename from packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_integration_controller.ts rename to packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md index 6a9b80d79c2f..a762642677a2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md @@ -661,7 +661,7 @@ for (const { name, args } of commands) { TODO ``` -In `m_ai_assistant_integration_controller.ts`: +In `ai_assistant_integration_controller.ts`: 1. Instantiate `GridCommands` with `this.component` and full command list 2. `buildContext()` — implemented directly on the controller (not delegated to `gridCommands`) From ca9acf84e42b1457baae473e96a25971bd93dc42 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 13:01:27 +0400 Subject: [PATCH 09/34] GridCommands class draft --- .../grid_core/ai_assistant/grid_commands.ts | 206 +++++++++++++++++- 1 file changed, 194 insertions(+), 12 deletions(-) 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 87dd1970b5b0..1bc956514b46 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 @@ -1,23 +1,205 @@ -import type { ExecuteGridAssistantAction } from '@js/common/ai-integration'; +import type { + ExecuteGridAssistantAction, +} from '@js/common/ai-integration'; +import messageLocalization from '@js/common/core/localization/message'; +import { isDefined, isObject } from '@js/core/utils/type'; +import { zodToJsonSchema } from 'zod-to-json-schema'; import type { InternalGrid } from '../m_types'; -import type { CommandResults } from './types'; +import type { + CommandCallbacks, + CommandResult, + CustomizeResponseText, + GridCommand, + JsonSchema, +} from './types'; + +const DEFAULT_SUCCESS_MESSAGE = 'dxDataGrid-aiAssistantSuccessMessage'; +const DEFAULT_FAILURE_MESSAGE = 'dxDataGrid-aiAssistantErrorMessage'; export class GridCommands { - constructor(private readonly gridInstance: InternalGrid) { + private readonly component: InternalGrid; + + private readonly commands: Map; + + private _executing = false; + + private _aborted = false; + + constructor(component: InternalGrid, commands: GridCommand[]) { + this.component = component; + this.commands = new Map(); + + for (const command of commands) { + if (this.commands.has(command.name)) { + throw new Error(`Duplicate command name: "${command.name}"`); + } + this.commands.set(command.name, command); + } + } + + private static success(message?: string): CommandResult { + return { + status: 'success', + message: message ?? messageLocalization.format(DEFAULT_SUCCESS_MESSAGE), + }; + } + + private static failure(message?: string): CommandResult { + return { + status: 'failure', + message: message ?? messageLocalization.format(DEFAULT_FAILURE_MESSAGE), + }; + } + + private static applyCustomizedResponseText( + result: CommandResult, + name: string, + args: Record, + customizeResponseText?: CustomizeResponseText, + ): void { + const customMessages = customizeResponseText?.(name, args); + const customMessage = customMessages?.[result.status]; + + if (isDefined(customMessage)) { + result.message = customMessage; + } + } + + public abort(): void { + this._aborted = true; + } + + public isAborted(): boolean { + return this._aborted; + } + + public isExecuting(): boolean { + return this._executing; + } + + public buildResponseSchema(): JsonSchema { + const branches = [...this.commands.values()].map((command) => { + const argsSchema = zodToJsonSchema(command.schema, { target: 'jsonSchema7' }); + + // Remove $schema from nested schemas since it's only necessary at root + delete argsSchema.$schema; + + return { + type: 'object', + description: command.description, + required: ['name', 'args'], + additionalProperties: false, + properties: { + name: { + type: 'string', + enum: [command.name], + }, + args: argsSchema, + }, + }; + }); + + return { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['actions'], + additionalProperties: false, + properties: { + actions: { + type: 'array', + description: 'The list of grid commands and corresponding arguments to execute', + items: { + anyOf: branches, + }, + }, + }, + }; } - // TODO: need to implement real validation logic - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public validate(actions: ExecuteGridAssistantAction[]): boolean { + public validateResponse(response: unknown): boolean { + const res = response as Record; + + if (!res || !Array.isArray(res.actions)) { + return false; + } + + for (const action of res.actions as Record[]) { + if (!action || typeof action.name !== 'string' || action.name === '') { + return false; + } + + const command = this.commands.get(action.name); + + if (!command) { + return false; + } + + if (!isDefined(action.args) || !isObject(action.args)) { + return false; + } + + const parseResult = command.schema.strict().safeParse(action.args); + + if (!parseResult.success) { + return false; + } + } + return true; } - // TODO: need to implement real command execution logic - public executeCommands(actions: ExecuteGridAssistantAction[]): Promise { - return Promise.resolve(actions.map((action) => ({ - status: action.name.includes('Error') ? 'failure' : 'success', - message: action.name, - }))); + private async executeCommand( + command: GridCommand, + args: Record, + callbacks: CommandCallbacks, + ): Promise { + try { + const executor = command.execute(this.component, callbacks); + return await executor(args); + } catch (e: unknown) { + console.error(`Error executing command "${command.name}":`, e); + return GridCommands.failure(`Error executing command "${command.name}"`); + } + } + + public async executeCommands( + commands: ExecuteGridAssistantAction[], + customizeResponseText?: CustomizeResponseText, + ): Promise { + if (this._executing) { + throw new Error('executeCommands is already in progress'); + } + + this._executing = true; + this._aborted = false; + + const results: CommandResult[] = []; + const callbacks: CommandCallbacks = { + success: GridCommands.success, + failure: GridCommands.failure, + }; + + for (const { name, args } of commands) { + if (this._aborted) { + results.push({ status: 'aborted', message: 'Execution Interrupted' }); + break; + } + + const command = this.commands.get(name); + + if (!command) { + throw new Error(`Unknown command: ${name}`); + } + // eslint-disable-next-line no-await-in-loop + const result = await this.executeCommand(command, args, callbacks); + + GridCommands.applyCustomizedResponseText(result, name, args, customizeResponseText); + results.push(result); + } + + this._executing = false; + + return results; } } From 008e4c9e291d0ae3adf7939d85c240377225852c Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 13:16:51 +0400 Subject: [PATCH 10/34] add localized messages --- .../grids/grid_core/ai_assistant/grid_commands.ts | 8 ++++++-- packages/devextreme/js/localization/messages/ar.json | 1 + packages/devextreme/js/localization/messages/bg.json | 1 + packages/devextreme/js/localization/messages/ca.json | 1 + packages/devextreme/js/localization/messages/cs.json | 1 + packages/devextreme/js/localization/messages/da.json | 1 + packages/devextreme/js/localization/messages/de.json | 1 + packages/devextreme/js/localization/messages/el.json | 1 + packages/devextreme/js/localization/messages/en.json | 1 + packages/devextreme/js/localization/messages/es.json | 1 + packages/devextreme/js/localization/messages/fa.json | 1 + packages/devextreme/js/localization/messages/fi.json | 1 + packages/devextreme/js/localization/messages/fr.json | 1 + packages/devextreme/js/localization/messages/hu.json | 1 + packages/devextreme/js/localization/messages/it.json | 1 + packages/devextreme/js/localization/messages/ja.json | 1 + packages/devextreme/js/localization/messages/ko.json | 1 + packages/devextreme/js/localization/messages/lt.json | 1 + packages/devextreme/js/localization/messages/lv.json | 1 + packages/devextreme/js/localization/messages/nb.json | 1 + packages/devextreme/js/localization/messages/nl.json | 1 + packages/devextreme/js/localization/messages/pl.json | 1 + packages/devextreme/js/localization/messages/pt.json | 1 + packages/devextreme/js/localization/messages/ro.json | 1 + packages/devextreme/js/localization/messages/ru.json | 1 + packages/devextreme/js/localization/messages/sl.json | 1 + packages/devextreme/js/localization/messages/sv.json | 1 + packages/devextreme/js/localization/messages/tr.json | 1 + packages/devextreme/js/localization/messages/uk.json | 1 + packages/devextreme/js/localization/messages/vi.json | 1 + packages/devextreme/js/localization/messages/zh-tw.json | 1 + packages/devextreme/js/localization/messages/zh.json | 1 + 32 files changed, 37 insertions(+), 2 deletions(-) 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 1bc956514b46..73944a0aac1c 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,6 +16,7 @@ import type { const DEFAULT_SUCCESS_MESSAGE = 'dxDataGrid-aiAssistantSuccessMessage'; const DEFAULT_FAILURE_MESSAGE = 'dxDataGrid-aiAssistantErrorMessage'; +const EXECUTION_ABORT_MESSAGE = 'dxDataGrid-aiAssistantExecutionAbortMessage'; export class GridCommands { private readonly component: InternalGrid; @@ -159,7 +160,7 @@ export class GridCommands { return await executor(args); } catch (e: unknown) { console.error(`Error executing command "${command.name}":`, e); - return GridCommands.failure(`Error executing command "${command.name}"`); + return GridCommands.failure(); } } @@ -182,7 +183,10 @@ export class GridCommands { for (const { name, args } of commands) { if (this._aborted) { - results.push({ status: 'aborted', message: 'Execution Interrupted' }); + results.push({ + status: 'aborted', + message: messageLocalization.format(EXECUTION_ABORT_MESSAGE), + }); break; } diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 33d11857c5cb..95a4548d92b4 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index f68df2524145..344c8580a5fe 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index f446f3a5c8c2..d820ab2e7218 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index 785f58af7c52..e5e551e3db55 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index 69bdd337733a..12adf88a77e7 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index 3fee48a0b31e..d57ab3762741 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index 6032fd55ca08..8a08c122170d 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index 434bdd5afbdc..276cb8dfeddf 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index f747e26c4147..a6c2b6601400 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index 9bae8be77e20..6d8dbeeaea96 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index 8ca811499b7a..79bca31153e1 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 0f3428c8fac5..211de17db919 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index 22d69250cb57..96c8b4987d38 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index 4063c932735e..209acb848652 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index c4aa61430d1c..a943d2a2a681 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ko.json b/packages/devextreme/js/localization/messages/ko.json index 6473191e40f6..05a6dec18786 100644 --- a/packages/devextreme/js/localization/messages/ko.json +++ b/packages/devextreme/js/localization/messages/ko.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index 3637384eefb8..c0cd590e862c 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index 15f6e62a00f2..3f9d49141d6e 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 316bbef01b1b..2b1379c1461f 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index ce80c7ac970f..1e3137f4662e 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 63896daf7815..55459f1e00fc 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index 8b00e699b848..f3a342511b3d 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index dd0ccd4c2743..4d7e6835420b 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index 89ef4f5cd1e9..a2679472af63 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index 8bced9445b7c..2c5a502f5845 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index f30460b44132..c68cec2e3b59 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index 601560583ed7..461e455463d0 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index 2ffcc25a5c38..310b10fb8acc 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index 32ee91c0deac..778713b2007b 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index 03862856cdc1..7a25712ce126 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index fc5904584b81..bbad56686352 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -113,6 +113,7 @@ "dxDataGrid-aiAssistantErrorMessageHeader": "Failed to process request", "dxDataGrid-aiAssistantSuccessMessage": "Success", "dxDataGrid-aiAssistantErrorMessage": "Error", + "dxDataGrid-aiAssistantExecutionAbortMessage": "Execution Interrupted", "dxDataGrid-aiAssistantClearButtonText": "Clear", "dxDataGrid-aiAssistantRegenerateButtonText": "Regenerate", "dxDataGrid-aiAChatEmptyViewMessage": "Chat is Empty", From 2a094a8a4c5c3ad5859d3a8448590159b4c999c6 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 19:31:30 +0400 Subject: [PATCH 11/34] added tests according to acceptance criteria --- .../__tests__/grid_commands.test.ts | 1103 +++++++++++++++++ .../grid_core/ai_assistant/grid_commands.ts | 1 + 2 files changed, 1104 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts 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 new file mode 100644 index 000000000000..12bd5e825c4d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts @@ -0,0 +1,1103 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import { z } from 'zod'; + +import type { InternalGrid } from '../../m_types'; +import { GridCommands } from '../grid_commands'; +import type { + CommandCallbacks, + CommandResult, + CustomizeResponseText, + GridCommand, +} from '../types'; + +interface Branch { + type: string; + description: string; + required: string[]; + additionalProperties: boolean; + properties: { + name: { type: string; enum: string[] }; + args: Record; + }; +} + +interface SchemaShape { + $schema: string; + type: string; + required: string[]; + additionalProperties: boolean; + properties: { + actions: { + type: string; + description: string; + items: { anyOf: Branch[] }; + }; + }; +} + +const createMockComponent = (): InternalGrid => ({}) as InternalGrid; + +const createMockCommand = ( + name: string, + overrides: Partial = {}, +): GridCommand => ({ + name, + description: `Test command: ${name}`, + schema: z.object({}), + execute: ( + _component: InternalGrid, + { success }: CommandCallbacks, + ) => async (): Promise => success(), + ...overrides, +}); + +describe('GridCommands', () => { + describe('constructor', () => { + it('should accept an empty commands array', () => { + const component = createMockComponent(); + + expect(() => new GridCommands(component, [])).not.toThrow(); + }); + + it('should store component for use by executeCommands', async () => { + const component = createMockComponent(); + const executeSpy = jest.fn( + ( + _comp: InternalGrid, + { success }: CommandCallbacks, + ) => async (): Promise => success('done'), + ); + const command = createMockCommand('test', { execute: executeSpy }); + const gridCommands = new GridCommands(component, [command]); + + await gridCommands.executeCommands([{ name: 'test', args: {} }]); + + expect(executeSpy).toHaveBeenCalledWith( + component, + expect.objectContaining({ + success: expect.any(Function), + failure: expect.any(Function), + }), + ); + }); + + it('should store commands in an internal registry indexed by name', () => { + const component = createMockComponent(); + const commandA = createMockCommand('commandA'); + const commandB = createMockCommand('commandB'); + const gridCommands = new GridCommands( + component, + [commandA, commandB], + ); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const commandNames = schema.properties.actions.items.anyOf.map( + (branch) => branch.properties.name.enum[0], + ); + + expect(commandNames).toEqual(['commandA', 'commandB']); + }); + + it('should throw if duplicate command names are provided', () => { + const component = createMockComponent(); + const command1 = createMockCommand('duplicate'); + const command2 = createMockCommand('duplicate'); + + expect( + () => new GridCommands(component, [command1, command2]), + ).toThrow('Duplicate command name: "duplicate"'); + }); + }); + + describe('success helper', () => { + 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(), + }); + const gridCommands = new GridCommands(component, [command]); + + const results = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results[0].status).toBe('success'); + expect(typeof results[0].message).toBe('string'); + }); + + 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'), + }); + const gridCommands = new GridCommands(component, [command]); + + const results = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results[0]).toEqual({ + status: 'success', + message: 'Custom msg', + }); + }); + }); + + describe('failure helper', () => { + 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(), + }); + const gridCommands = new GridCommands(component, [command]); + + const results = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results[0].status).toBe('failure'); + expect(typeof results[0].message).toBe('string'); + }); + + 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'), + }); + const gridCommands = new GridCommands(component, [command]); + + const results = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results[0]).toEqual({ + status: 'failure', + message: 'Custom msg', + }); + }); + }); + + describe('buildResponseSchema', () => { + it('should return valid JSON Schema draft-07 object', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + const schema = gridCommands.buildResponseSchema() as SchemaShape; + + expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#'); + expect(schema.type).toBe('object'); + expect(schema.required).toEqual(['actions']); + expect(schema.additionalProperties).toBe(false); + }); + + it('should have anyOf with one branch per registered command', () => { + const commandA = createMockCommand('commandA'); + const commandB = createMockCommand('commandB'); + const gridCommands = new GridCommands( + createMockComponent(), + [commandA, commandB], + ); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const { anyOf } = schema.properties.actions.items; + + expect(anyOf).toHaveLength(2); + }); + + it('should include description from GridCommand.description in each branch', () => { + const command = createMockCommand('sorting', { + description: 'Apply sorting to one or more columns', + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const branch = schema.properties.actions.items.anyOf[0]; + + expect(branch.description).toBe('Apply sorting to one or more columns'); + }); + + it('should have name.enum with exactly one command name in each branch', () => { + const command = createMockCommand('sorting'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const branch = schema.properties.actions.items.anyOf[0]; + + expect(branch.properties.name).toEqual({ + type: 'string', + enum: ['sorting'], + }); + }); + + it('should have args with command schema including additionalProperties false', () => { + const command = createMockCommand('test', { + schema: z.object({ + dataField: z.string(), + sortOrder: z.enum(['asc', 'desc']), + }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const { args } = schema.properties.actions.items.anyOf[0].properties; + + expect(args.type).toBe('object'); + expect(args.additionalProperties).toBe(false); + expect(args.required).toEqual(['dataField', 'sortOrder']); + expect(args.properties).toBeDefined(); + }); + + it('should set additionalProperties false on every object level', () => { + const command = createMockCommand('test'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + + // Root level + expect(schema.additionalProperties).toBe(false); + // Branch level + const branch = schema.properties.actions.items.anyOf[0]; + expect(branch.additionalProperties).toBe(false); + // Args level + expect(branch.properties.args.additionalProperties).toBe(false); + }); + + it('should not have anyOf at root schema level', () => { + const command = createMockCommand('test'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as Record; + + expect(schema.anyOf).toBeUndefined(); + expect(schema.oneOf).toBeUndefined(); + expect(schema.allOf).toBeUndefined(); + }); + + it('should return empty anyOf with no commands registered', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const { anyOf } = schema.properties.actions.items; + + expect(anyOf).toEqual([]); + }); + + it('should produce empty args schema for no-arg commands', () => { + 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; + + expect(args.type).toBe('object'); + expect(args.additionalProperties).toBe(false); + expect(args.properties).toEqual({}); + }); + + it('should produce different schemas for different command registries', () => { + const gc1 = new GridCommands(createMockComponent(), [ + createMockCommand('commandA'), + ]); + const gc2 = new GridCommands(createMockComponent(), [ + createMockCommand('commandB'), + ]); + + const schema1 = gc1.buildResponseSchema() as SchemaShape; + const schema2 = gc2.buildResponseSchema() as SchemaShape; + + const names1 = schema1.properties.actions.items.anyOf.map( + (b) => b.properties.name.enum[0], + ); + const names2 = schema2.properties.actions.items.anyOf.map( + (b) => b.properties.name.enum[0], + ); + + expect(names1).toEqual(['commandA']); + expect(names2).toEqual(['commandB']); + }); + + it('should produce correct required array for commands with required fields', () => { + const command = createMockCommand('test', { + schema: z.object({ + field1: z.string(), + field2: z.number().optional(), + }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const schema = gridCommands.buildResponseSchema() as SchemaShape; + const { args } = schema.properties.actions.items.anyOf[0].properties; + + expect(args.required).toEqual(['field1']); + }); + }); + + describe('validateResponse', () => { + it('should return true for valid response with known command names and correct args', () => { + const command = createMockCommand('test', { + schema: z.object({ value: z.string() }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'test', args: { value: 'hello' } }], + }); + + expect(result).toBe(true); + }); + + it('should return false if response.actions is not an array', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + expect(gridCommands.validateResponse({ actions: 'not-array' })).toBe(false); + expect(gridCommands.validateResponse({ actions: 123 })).toBe(false); + expect(gridCommands.validateResponse({ actions: {} })).toBe(false); + }); + + it('should return false if response.actions is missing', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + expect(gridCommands.validateResponse({})).toBe(false); + expect(gridCommands.validateResponse({ other: [] })).toBe(false); + }); + + it('should return false if response is null or undefined', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + expect(gridCommands.validateResponse(null)).toBe(false); + expect(gridCommands.validateResponse(undefined)).toBe(false); + }); + + it('should return false if any action has an unknown name', () => { + const command = createMockCommand('known'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'unknown', args: {} }], + }); + + expect(result).toBe(false); + }); + + it('should return false if any action name is not a string', () => { + const gridCommands = new GridCommands(createMockComponent(), [ + createMockCommand('test'), + ]); + + expect(gridCommands.validateResponse({ + actions: [{ name: 123, args: {} }], + })).toBe(false); + + expect(gridCommands.validateResponse({ + actions: [{ name: true, args: {} }], + })).toBe(false); + }); + + it('should return false if any action name is an empty string', () => { + const gridCommands = new GridCommands(createMockComponent(), [ + createMockCommand('test'), + ]); + + const result = gridCommands.validateResponse({ + actions: [{ name: '', args: {} }], + }); + + expect(result).toBe(false); + }); + + it('should return false if any action is missing name or args', () => { + const gridCommands = new GridCommands(createMockComponent(), [ + createMockCommand('test'), + ]); + + expect(gridCommands.validateResponse({ + actions: [{ args: {} }], + })).toBe(false); + + expect(gridCommands.validateResponse({ + actions: [{ name: 'test' }], + })).toBe(false); + }); + + it('should return false if any action args is null', () => { + const gridCommands = new GridCommands(createMockComponent(), [ + createMockCommand('test'), + ]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'test', args: null }], + }); + + expect(result).toBe(false); + }); + + it('should return false if any action args has wrong types for required properties', () => { + const command = createMockCommand('test', { + schema: z.object({ value: z.string() }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'test', args: { value: 123 } }], + }); + + expect(result).toBe(false); + }); + + it('should return false if any action args is missing required properties', () => { + const command = createMockCommand('test', { + schema: z.object({ value: z.string() }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'test', args: {} }], + }); + + expect(result).toBe(false); + }); + + it('should return false if any action args contains extra properties', () => { + const command = createMockCommand('test', { + schema: z.object({ value: z.string() }), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'test', args: { value: 'ok', extra: true } }], + }); + + expect(result).toBe(false); + }); + + it('should return true for an empty actions array', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + const result = gridCommands.validateResponse({ actions: [] }); + + expect(result).toBe(true); + }); + + it('should return true for no-arg commands when args is empty object', () => { + const command = createMockCommand('clearFilter'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [{ name: 'clearFilter', args: {} }], + }); + + expect(result).toBe(true); + }); + + it('should reject entire response on first mismatch', () => { + const command = createMockCommand('valid'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const result = gridCommands.validateResponse({ + actions: [ + { name: 'valid', args: {} }, + { name: 'invalid', args: {} }, + ], + }); + + expect(result).toBe(false); + }); + }); + + describe('executeCommands', () => { + it('should return empty array for empty commands', async () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + const results = await gridCommands.executeCommands([]); + + expect(results).toEqual([]); + }); + + it('should execute commands in the order provided', async () => { + const executionOrder: string[] = []; + + const commandA = createMockCommand('a', { + execute: (_comp, { success }) => async () => { + executionOrder.push('a'); + return success(); + }, + }); + const commandB = createMockCommand('b', { + execute: (_comp, { success }) => async () => { + executionOrder.push('b'); + return success(); + }, + }); + const commandC = createMockCommand('c', { + execute: (_comp, { success }) => async () => { + executionOrder.push('c'); + return success(); + }, + }); + const gridCommands = new GridCommands( + createMockComponent(), + [commandA, commandB, commandC], + ); + + await gridCommands.executeCommands([ + { name: 'a', args: {} }, + { name: 'b', args: {} }, + { name: 'c', args: {} }, + ]); + + expect(executionOrder).toEqual(['a', 'b', 'c']); + }); + + it('should await each command before starting the next', async () => { + const executionOrder: string[] = []; + + const commandA = createMockCommand('a', { + execute: (_comp, { success }) => async () => { + await new Promise((resolve) => { setTimeout(resolve, 50); }); + executionOrder.push('a'); + return success(); + }, + }); + const commandB = createMockCommand('b', { + execute: (_comp, { success }) => async () => { + executionOrder.push('b'); + return success(); + }, + }); + const gridCommands = new GridCommands( + createMockComponent(), + [commandA, commandB], + ); + + await gridCommands.executeCommands([ + { name: 'a', args: {} }, + { name: 'b', args: {} }, + ]); + + expect(executionOrder).toEqual(['a', 'b']); + }); + + it('should return one CommandResult per executed command', async () => { + const commandA = createMockCommand('a'); + const commandB = createMockCommand('b'); + const gridCommands = new GridCommands( + createMockComponent(), + [commandA, commandB], + ); + + const results = await gridCommands.executeCommands([ + { name: 'a', args: {} }, + { name: 'b', args: {} }, + ]); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('success'); + expect(results[1].status).toBe('success'); + }); + + it('should produce failure result when executor throws synchronously', async () => { + const command = createMockCommand('throwing', { + execute: () => () => { + throw new Error('sync error'); + }, + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const results = await gridCommands.executeCommands([ + { name: 'throwing', args: {} }, + ]); + + expect(results[0].status).toBe('failure'); + }); + + it('should produce failure result when async executor rejects', async () => { + const command = createMockCommand('rejecting', { + execute: () => async () => { + throw new Error('async error'); + }, + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const results = await gridCommands.executeCommands([ + { name: 'rejecting', args: {} }, + ]); + + expect(results[0].status).toBe('failure'); + }); + + it('should throw for unknown command name', async () => { + const gridCommands = new GridCommands(createMockComponent(), [ + createMockCommand('known'), + ]); + + await expect( + gridCommands.executeCommands([{ name: 'unknown', args: {} }]), + ).rejects.toThrow('Unknown command: unknown'); + }); + + it('should reset _executing after unknown command throw so subsequent calls work', async () => { + const command = createMockCommand('known'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + await expect( + gridCommands.executeCommands([{ name: 'unknown', args: {} }]), + ).rejects.toThrow(); + + const results = await gridCommands.executeCommands([ + { name: 'known', args: {} }, + ]); + + expect(results[0].status).toBe('success'); + }); + + it('should throw if called while another executeCommands is in progress', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const blockingCommand = createMockCommand('blocking', { + execute: (_comp, { success }) => async () => { + await expect( + gridCommands.executeCommands([]), + ).rejects.toThrow('executeCommands is already in progress'); + return success(); + }, + }); + + gridCommands = new GridCommands(component, [blockingCommand]); + + await gridCommands.executeCommands([ + { name: 'blocking', args: {} }, + ]); + }); + + it('should allow subsequent calls after first call completes', async () => { + const command = createMockCommand('test'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const results1 = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + const results2 = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results1[0].status).toBe('success'); + expect(results2[0].status).toBe('success'); + }); + + it('should record success and failure statuses correctly', async () => { + const successCommand = createMockCommand('ok', { + execute: (_comp, { success }) => async () => success(), + }); + const failCommand = createMockCommand('fail', { + execute: (_comp, { failure }) => async () => failure(), + }); + const gridCommands = new GridCommands( + createMockComponent(), + [successCommand, failCommand], + ); + + const results = await gridCommands.executeCommands([ + { name: 'ok', args: {} }, + { name: 'fail', args: {} }, + ]); + + expect(results[0].status).toBe('success'); + expect(results[1].status).toBe('failure'); + }); + }); + + describe('abort', () => { + it('should stop execution mid-way and return partial results plus one aborted entry', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const first = createMockCommand('first', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + return success('first done'); + }, + }); + const second = createMockCommand('second'); + const third = createMockCommand('third'); + + gridCommands = new GridCommands(component, [first, second, third]); + + const results = await gridCommands.executeCommands([ + { name: 'first', args: {} }, + { name: 'second', args: {} }, + { name: 'third', args: {} }, + ]); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ status: 'success', message: 'first done' }); + expect(results[1].status).toBe('aborted'); + }); + + it('should be idempotent - calling multiple times has no additional effect', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const first = createMockCommand('first', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + gridCommands.abort(); + gridCommands.abort(); + return success(); + }, + }); + const second = createMockCommand('second'); + + gridCommands = new GridCommands(component, [first, second]); + + const results = await gridCommands.executeCommands([ + { name: 'first', args: {} }, + { name: 'second', args: {} }, + ]); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('success'); + expect(results[1].status).toBe('aborted'); + }); + + it('should only add one aborted entry for the first skipped command', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const first = createMockCommand('first', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + return success(); + }, + }); + const second = createMockCommand('second'); + const third = createMockCommand('third'); + const fourth = createMockCommand('fourth'); + + gridCommands = new GridCommands( + component, + [first, second, third, fourth], + ); + + const results = await gridCommands.executeCommands([ + { name: 'first', args: {} }, + { name: 'second', args: {} }, + { name: 'third', args: {} }, + { name: 'fourth', args: {} }, + ]); + + expect(results).toHaveLength(2); + expect(results[1].status).toBe('aborted'); + }); + + it('should reset _aborted on next successful executeCommands start', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const abortSimulation = createMockCommand('abort', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + return success(); + }, + }); + const normal = createMockCommand('normal'); + + gridCommands = new GridCommands(component, [abortSimulation, normal]); + + // First call: abort triggered during execution + const results1 = await gridCommands.executeCommands([ + { name: 'abort', args: {} }, + { name: 'normal', args: {} }, + ]); + expect(results1[1].status).toBe('aborted'); + + // Second call: _aborted was reset, runs normally + const results2 = await gridCommands.executeCommands([ + { name: 'normal', args: {} }, + ]); + expect(results2[0].status).toBe('success'); + }); + + it('should not reset _aborted when concurrent call is rejected by reentrancy guard', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const blockingCommand = createMockCommand('blocking', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + try { + await gridCommands.executeCommands([]); + } catch { + // Expected: reentrancy rejection + } + return success(); + }, + }); + const nextCommand = createMockCommand('next'); + + gridCommands = new GridCommands(component, [blockingCommand, nextCommand]); + + const results = await gridCommands.executeCommands([ + { name: 'blocking', args: {} }, + { name: 'next', args: {} }, + ]); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('success'); + expect(results[1].status).toBe('aborted'); + }); + }); + + describe('isExecuting', () => { + it('should return false before executeCommands is called', () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + expect(gridCommands.isExecuting()).toBe(false); + }); + + it('should return false after executeCommands completes', async () => { + const gridCommands = new GridCommands(createMockComponent(), []); + + await gridCommands.executeCommands([]); + + expect(gridCommands.isExecuting()).toBe(false); + }); + + it('should return true while executeCommands is in progress', async () => { + const component = createMockComponent(); + let capturedIsExecuting = false; + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const executeSpy = jest.fn(( + _comp: InternalGrid, + { success }: CommandCallbacks, + ) => async (): Promise => { + capturedIsExecuting = gridCommands.isExecuting(); + return success(); + }); + const spyCommand = createMockCommand('spy', { + execute: executeSpy, + }); + gridCommands = new GridCommands(component, [spyCommand]); + + await gridCommands.executeCommands([ + { name: 'spy', args: {} }, + ]); + + expect(capturedIsExecuting).toBe(true); + }); + + it('should return false after abort-induced exit', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const abortSimulation = createMockCommand('abort', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + return success(); + }, + }); + const next = createMockCommand('next'); + + gridCommands = new GridCommands(component, [abortSimulation, next]); + + await gridCommands.executeCommands([ + { name: 'abort', args: {} }, + { name: 'next', args: {} }, + ]); + + expect(gridCommands.isExecuting()).toBe(false); + }); + + it('should not change isExecuting state when concurrent call is rejected', async () => { + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + let isExecutingAfterRejection = true; + + const blockingCommand = createMockCommand('blocking', { + execute: (_comp, { success }) => async () => { + try { + await gridCommands.executeCommands([]); + } catch { + isExecutingAfterRejection = gridCommands.isExecuting(); + } + return success(); + }, + }); + + gridCommands = new GridCommands(component, [blockingCommand]); + + await gridCommands.executeCommands([ + { name: 'blocking', args: {} }, + ]); + + // isExecuting should still be true during the outer call + expect(isExecutingAfterRejection).toBe(true); + // After completion, it's false + expect(gridCommands.isExecuting()).toBe(false); + }); + }); + + describe('customizeResponseText', () => { + it('should use default messages when customizeResponseText is not provided', async () => { + const command = createMockCommand('test', { + execute: (_comp, { success }) => async () => success('default msg'), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const results = await gridCommands.executeCommands([ + { name: 'test', args: {} }, + ]); + + expect(results[0].message).toBe('default msg'); + }); + + it('should call customizeResponseText once per executed command with correct args', async () => { + const customizeSpy = jest.fn(() => undefined); + const command = createMockCommand('test'); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + await gridCommands.executeCommands( + [ + { name: 'test', args: { key: 'val1' } }, + { name: 'test', args: { key: 'val2' } }, + ], + customizeSpy, + ); + + expect(customizeSpy).toHaveBeenCalledTimes(2); + expect(customizeSpy).toHaveBeenNthCalledWith(1, 'test', { key: 'val1' }); + expect(customizeSpy).toHaveBeenNthCalledWith(2, 'test', { key: 'val2' }); + }); + + it('should replace both messages when returning { success, failure }', async () => { + const customizeResponseText: CustomizeResponseText = () => ({ + success: 'Custom success', + failure: 'Custom failure', + }); + + const successCommand = createMockCommand('ok', { + execute: (_comp, { success }) => async () => success('default'), + }); + const failCommand = createMockCommand('fail', { + execute: (_comp, { failure }) => async () => failure('default'), + }); + const gridCommands = new GridCommands( + createMockComponent(), + [successCommand, failCommand], + ); + + const results = await gridCommands.executeCommands( + [ + { name: 'ok', args: {} }, + { name: 'fail', args: {} }, + ], + customizeResponseText, + ); + + expect(results[0].message).toBe('Custom success'); + expect(results[1].message).toBe('Custom failure'); + }); + + it('should only replace success message when returning { success } and keep default failure', async () => { + const customizeResponseText: CustomizeResponseText = () => ({ + success: 'Custom success', + }); + + const failCommand = createMockCommand('fail', { + execute: (_comp, { failure }) => async () => failure('default failure'), + }); + const gridCommands = new GridCommands(createMockComponent(), [failCommand]); + + const results = await gridCommands.executeCommands( + [{ name: 'fail', args: {} }], + customizeResponseText, + ); + + expect(results[0].message).toBe('default failure'); + }); + + it('should only replace failure message when returning { failure } and keep default success', async () => { + const customizeResponseText: CustomizeResponseText = () => ({ + failure: 'Custom failure', + }); + + const successCommand = createMockCommand('ok', { + execute: (_comp, { success }) => async () => success('default success'), + }); + const gridCommands = new GridCommands(createMockComponent(), [successCommand]); + + const results = await gridCommands.executeCommands( + [{ name: 'ok', args: {} }], + customizeResponseText, + ); + + expect(results[0].message).toBe('default success'); + }); + + it('should leave default message when customizeResponseText returns undefined', async () => { + const customizeResponseText: CustomizeResponseText = () => undefined; + + const command = createMockCommand('test', { + execute: (_comp, { success }) => async () => success('original'), + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + + const results = await gridCommands.executeCommands( + [{ name: 'test', args: {} }], + customizeResponseText, + ); + + expect(results[0].message).toBe('original'); + }); + + it('should not call customizeResponseText for aborted entry', async () => { + const customizeSpy = jest.fn(() => ({ + success: 'custom', + })); + const component = createMockComponent(); + // eslint-disable-next-line @typescript-eslint/init-declarations + let gridCommands: GridCommands; + + const abortSimulation = createMockCommand('abort', { + execute: (_comp, { success }) => async () => { + gridCommands.abort(); + return success(); + }, + }); + const skipped = createMockCommand('skipped'); + + gridCommands = new GridCommands(component, [abortSimulation, skipped]); + + const results = await gridCommands.executeCommands( + [ + { name: 'abort', args: {} }, + { name: 'skipped', args: {} }, + ], + customizeSpy, + ); + + expect(customizeSpy).toHaveBeenCalledTimes(1); + expect(customizeSpy).toHaveBeenCalledWith('abort', {}); + expect(results[1].status).toBe('aborted'); + }); + + it('should not call customizeResponseText when no commands are executed', async () => { + const customizeSpy = jest.fn(() => undefined); + const gridCommands = new GridCommands(createMockComponent(), []); + + await gridCommands.executeCommands([], customizeSpy); + + expect(customizeSpy).not.toHaveBeenCalled(); + }); + }); +}); 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 73944a0aac1c..39d2bd9aff91 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 @@ -193,6 +193,7 @@ export class GridCommands { const command = this.commands.get(name); if (!command) { + this._executing = false; throw new Error(`Unknown command: ${name}`); } // eslint-disable-next-line no-await-in-loop From 6466470de832ba80c038802026514783246fa7bb Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 20:15:09 +0400 Subject: [PATCH 12/34] fix types after rebasing --- .../__internal/grids/grid_core/ai_assistant/grid_commands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 39d2bd9aff91..93841a034faa 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 @@ -21,7 +21,7 @@ const EXECUTION_ABORT_MESSAGE = 'dxDataGrid-aiAssistantExecutionAbortMessage'; export class GridCommands { private readonly component: InternalGrid; - private readonly commands: Map; + private readonly commands: Map>>; private _executing = false; @@ -151,7 +151,7 @@ export class GridCommands { } private async executeCommand( - command: GridCommand, + command: GridCommand>, args: Record, callbacks: CommandCallbacks, ): Promise { From 1b15cb507a28f95c543068e8cccaea297c0ae943 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 21:24:15 +0400 Subject: [PATCH 13/34] fix after rebasing --- .../__tests__/grid_commands.test.ts | 42 +++++++++---------- .../grid_core/ai_assistant/grid_commands.ts | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) 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 12bd5e825c4d..5a97fed3ff7b 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 @@ -339,7 +339,7 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'test', args: { value: 'hello' } }], }); @@ -349,30 +349,30 @@ describe('GridCommands', () => { it('should return false if response.actions is not an array', () => { const gridCommands = new GridCommands(createMockComponent(), []); - expect(gridCommands.validateResponse({ actions: 'not-array' })).toBe(false); - expect(gridCommands.validateResponse({ actions: 123 })).toBe(false); - expect(gridCommands.validateResponse({ actions: {} })).toBe(false); + expect(gridCommands.validate({ actions: 'not-array' })).toBe(false); + expect(gridCommands.validate({ actions: 123 })).toBe(false); + expect(gridCommands.validate({ actions: {} })).toBe(false); }); it('should return false if response.actions is missing', () => { const gridCommands = new GridCommands(createMockComponent(), []); - expect(gridCommands.validateResponse({})).toBe(false); - expect(gridCommands.validateResponse({ other: [] })).toBe(false); + expect(gridCommands.validate({})).toBe(false); + expect(gridCommands.validate({ other: [] })).toBe(false); }); it('should return false if response is null or undefined', () => { const gridCommands = new GridCommands(createMockComponent(), []); - expect(gridCommands.validateResponse(null)).toBe(false); - expect(gridCommands.validateResponse(undefined)).toBe(false); + expect(gridCommands.validate(null)).toBe(false); + expect(gridCommands.validate(undefined)).toBe(false); }); it('should return false if any action has an unknown name', () => { const command = createMockCommand('known'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'unknown', args: {} }], }); @@ -384,11 +384,11 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - expect(gridCommands.validateResponse({ + expect(gridCommands.validate({ actions: [{ name: 123, args: {} }], })).toBe(false); - expect(gridCommands.validateResponse({ + expect(gridCommands.validate({ actions: [{ name: true, args: {} }], })).toBe(false); }); @@ -398,7 +398,7 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: '', args: {} }], }); @@ -410,11 +410,11 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - expect(gridCommands.validateResponse({ + expect(gridCommands.validate({ actions: [{ args: {} }], })).toBe(false); - expect(gridCommands.validateResponse({ + expect(gridCommands.validate({ actions: [{ name: 'test' }], })).toBe(false); }); @@ -424,7 +424,7 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'test', args: null }], }); @@ -437,7 +437,7 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'test', args: { value: 123 } }], }); @@ -450,7 +450,7 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'test', args: {} }], }); @@ -463,7 +463,7 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'test', args: { value: 'ok', extra: true } }], }); @@ -473,7 +473,7 @@ describe('GridCommands', () => { it('should return true for an empty actions array', () => { const gridCommands = new GridCommands(createMockComponent(), []); - const result = gridCommands.validateResponse({ actions: [] }); + const result = gridCommands.validate({ actions: [] }); expect(result).toBe(true); }); @@ -482,7 +482,7 @@ describe('GridCommands', () => { const command = createMockCommand('clearFilter'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [{ name: 'clearFilter', args: {} }], }); @@ -493,7 +493,7 @@ describe('GridCommands', () => { const command = createMockCommand('valid'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validateResponse({ + const result = gridCommands.validate({ actions: [ { name: 'valid', args: {} }, { name: 'invalid', args: {} }, 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 93841a034faa..9f0797e0b803 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 @@ -118,7 +118,7 @@ export class GridCommands { }; } - public validateResponse(response: unknown): boolean { + public validate(response: unknown): boolean { const res = response as Record; if (!res || !Array.isArray(res.actions)) { From 103dbfd8a31d8e094eba8f743e485ad40aa3524f Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 21:31:23 +0400 Subject: [PATCH 14/34] fix ai chat integration --- .../ai_assistant/ai_assistant_controller.ts | 4 ++-- .../grids/grid_core/ai_assistant/types.ts | 13 ++----------- 2 files changed, 4 insertions(+), 13 deletions(-) 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 8b0917ec3a26..a4de95b3e8c2 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 @@ -8,9 +8,9 @@ import { fromPromise } from '@ts/core/utils/m_deferred'; import { hasCommandErrors } from '../ai_chat/utils'; import { Controller } from '../m_modules'; +import { AIAssistantIntegrationController } from './ai_assistant_integration_controller'; import { AI_ASSISTANT_AUTHOR, AI_ASSISTANT_AUTHOR_ID, MessageStatus } from './const'; import { GridCommands } from './grid_commands'; -import { AIAssistantIntegrationController } from './m_ai_assistant_integration_controller'; import type { CommandResults, } from './types'; @@ -87,7 +87,7 @@ export class AIAssistantController extends Controller { } public init(): void { - this.gridCommands = new GridCommands(this.component); + this.gridCommands = new GridCommands(this.component, []); this.messageStore = new ArrayStore({ key: 'id', }); 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 9a0dc1afc5a2..2f0a7ddad218 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 @@ -14,6 +14,8 @@ export interface CommandResult { message: string; } +export type CommandResults = CommandResult[]; + export interface CommandCallbacks { success: (message?: string) => CommandResult; failure: (message?: string) => CommandResult; @@ -34,17 +36,6 @@ export interface GridCommand { execute: (component: InternalGrid, callbacks: CommandCallbacks) => CommandExecutor; } -export interface Command { - command: string; - args: Record; -} -export interface CommandResponse { - commands: Command[]; - explanation: string; -} - -export type CommandResults = CommandResult[]; - export interface CommandMessages { success: string; failure: string; From 638581a4e3dd11ce0864255e34e34499400f0caa Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 29 Apr 2026 22:12:13 +0400 Subject: [PATCH 15/34] remove temporarily added specification --- .../ai_assistant/grid_commands_spec.md | 740 ------------------ 1 file changed, 740 deletions(-) delete mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md deleted file mode 100644 index a762642677a2..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands_spec.md +++ /dev/null @@ -1,740 +0,0 @@ -# GridCommands Specification - -## Overview - -`GridCommands` is a utility class that bridges AI responses and DataGrid API calls. It maintains a registry of command descriptors, builds a unified JSON Schema (draft-07) for AI response structure, validates responses, executes commands sequentially (awaiting async ones), and returns user-facing result messages. - -## Dependencies - -- **zod** `3.24.4` — used to define per-command `args` schemas in TypeScript and to validate AI responses at runtime -- **zod-to-json-schema** `3.24.6` — converts Zod schemas to JSON Schema draft-07 for `buildResponseSchema()` output sent to the LLM - -Each `GridCommand.schema` is defined as a `ZodObject` instead of a raw `JsonSchema`. `buildResponseSchema()` converts registered Zod schemas to JSON Schema via `zodToJsonSchema`. `validateResponse()` uses Zod's `.safeParse()` for per-command arg validation, giving structured error details internally. - -## Flow - -1. `GridCommands.buildResponseSchema()` — merges per-command schemas into unified JSON Schema draft-07 -2. `AIAssistantIntegrationController.buildContext()` — collects current grid state for the AI prompt (uses `this.component`) -3. AI returns `ExecuteGridAssistantCommandResult` (`{ actions: [{ name, args }] }`) -4. `GridCommands.validateResponse(response)` — structural validation against merged schema; any mismatch → fail entirely -5. `GridCommands.executeCommands(actions, customizeResponseText?)` — runs commands in AI-returned order, awaiting each before next; returns `CommandResult[]` used directly to render response - -## File Structure - -``` -ai_assistant/ - grid_commands.ts # GridCommands class - types.ts # GridCommand interface, CommandResult, shared types - commands/ - sorting.ts # sorting, clearSorting - filtering.ts # filterValue, clearFilter, searching - grouping.ts # grouping - paging.ts # page, pageSize, groupPaging - selection.ts # selectByKeys, selectByIndexes, selectAll, deselectAll, clearSelection - columns.ts # columnsVisibility, columnsReorder, columnsPinning, columnsResize - summary.ts # summary, clearSummary - focus.ts # rowFocusing - __tests__/ - grid_commands.test.ts # GridCommands class tests - commands/ - sorting.test.ts - filtering.test.ts - grouping.test.ts - paging.test.ts - selection.test.ts - columns.test.ts - summary.test.ts - focus.test.ts -``` - ---- - -## Types (`types.ts`) - -```typescript -import type { ZodObject, ZodRawShape } from 'zod'; -import type { JsonSchema7Type } from 'zod-to-json-schema'; - -/** JSON Schema draft-07 object sent to the LLM. */ -type JsonSchema = JsonSchema7Type & { - $schema?: string; -}; - -type CommandStatus = 'success' | 'failure' | 'aborted'; - -interface CommandResult { - status: CommandStatus; - message: string; -} - -interface CommandCallbacks { - success: (message?: string) => CommandResult; - failure: (message?: string) => CommandResult; -} - -type CommandExecutor = (args: Record) => Promise; - -interface GridCommand { - name: string; - description: string; // Human-readable command purpose, used as branch-level description in schema - schema: ZodObject; // Zod schema defining the `args` shape; converted to JSON Schema by buildResponseSchema(), used by validateResponse() via .safeParse() - execute: ( - component: InternalGrid, - callbacks: CommandCallbacks, - ) => CommandExecutor; -} -``` - -### Execute pattern - -`execute` is a factory: it receives `component` and `callbacks`, returns a `CommandExecutor` function that takes `args` and returns `Promise`. The `callbacks.success()` and `callbacks.failure()` helpers are provided by `GridCommands` and create `CommandResult` with the corresponding status and a custom or default message. - -```typescript -// Example command execute: -execute: (component, { success, failure }) => async (args) => { - try { - await Promise.resolve(component.option(args.newOptionValue)); - return success('Sorting applied'); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - return failure(`Failed to apply sorting: ${message}`); - } -} -``` - ---- - -## GridCommands Class (`grid_commands.ts`) - -### Constructor - -```typescript -constructor(component: InternalGrid, commands: GridCommand[]) -``` - -**Acceptance criteria:** -- [ ] Stores `component` for use by `executeCommands` -- [ ] Stores commands in an internal registry indexed by `name` -- [ ] Throws if duplicate command names are provided -- [ ] Accepts an empty commands array (no commands registered) - -### Helper methods (passed to command execute as `CommandCallbacks`) - -#### `success(message?: string): CommandResult` - -Returns `{ status: 'success', message: message ?? defaultSuccessMessage }`. - -#### `failure(message?: string): CommandResult` - -Returns `{ status: 'failure', message: message ?? defaultFailureMessage }`. - -**Acceptance criteria:** -- [ ] `success()` without argument returns `CommandResult` with `status: 'success'` and a default message -- [ ] `success('Custom msg')` returns `CommandResult` with `status: 'success'` and `message: 'Custom msg'` -- [ ] `failure()` without argument returns `CommandResult` with `status: 'failure'` and a default message -- [ ] `failure('Custom msg')` returns `CommandResult` with `status: 'failure'` and `message: 'Custom msg'` - -### `abort(): void` - -Sets an internal `_aborted` flag to `true`. When `executeCommands` is running, it checks this flag before each command iteration. If set, execution stops and returns partial results with an `aborted` entry for the first skipped command. Calling `abort()` when not executing still sets the flag, but the flag is only reset when `executeCommands` actually begins execution (i.e., passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset `_aborted`. - -**Acceptance criteria:** -- [ ] Sets `_aborted` to `true` -- [ ] Idempotent — calling multiple times has no additional effect -- [ ] Calling when not executing sets the flag; the flag persists until the next successful `executeCommands` start - -### `isExecuting(): boolean` - -Returns the current value of the internal `_executing` flag (the same flag used by the reentrancy guard). - -**Acceptance criteria:** -- [ ] Returns `true` while `executeCommands` is in progress -- [ ] Returns `false` before `executeCommands` is called -- [ ] Returns `false` after `executeCommands` completes (normally, via abort, or via reentrancy rejection) - -### JSON Schema LLM Constraints - -Not all JSON Schema draft-07 features are supported by LLMs. The following constraints apply when building schemas: - -**Not supported (do not use):** -- `oneOf`, `anyOf` on **root** level; `allOf` at any level -- `not` (ignored by LLMs) -- `pattern`, regex-based validation -- `dependencies` -- `if` / `then` / `else` - -**Allowed on non-root level:** -- `anyOf` — used inside `items` to bind each command name to its specific args schema - -**Partially supported (always add a `description` when using):** -- `format` -- `minimum` / `maximum` -- `examples` - -**Required:** -- `additionalProperties` must always be set to `false` - -### `buildResponseSchema(): JsonSchema` - -Generates a unified JSON Schema draft-07 using `anyOf` inside `items` to bind each command name to its specific args schema. Each `anyOf` branch is a complete `{name, args}` object with a branch-level `description`. Each `GridCommand.schema` defines the full `args` object schema including `required`, `properties`, and `additionalProperties: false`. - -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "required": ["actions"], - "additionalProperties": false, - "properties": { - "actions": { - "type": "array", - "description": "List of grid commands to execute", - "items": { - "anyOf": [ - { - "type": "object", - "description": "Apply sorting to one or more columns", - "required": ["name", "args"], - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "enum": ["sorting"] - }, - "args": - } - }, - { - "type": "object", - "description": "Remove all sorting", - "required": ["name", "args"], - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "enum": ["clearSorting"] - }, - "args": { - "type": "object", - "additionalProperties": false, - "properties": {} - } - } - } - ] - } - } - } -} -``` - -No-arg commands (e.g. `clearSorting`, `selectAll`) use `args: { type: "object", additionalProperties: false, properties: {} }`. - -**Acceptance criteria:** -- [ ] Returns valid JSON Schema draft-07 object -- [ ] `actions.items` uses `anyOf` with one branch per registered command -- [ ] Each branch has a `description` with the command's purpose (from `GridCommand.description`) -- [ ] Each branch has `name.enum` with exactly one command name -- [ ] Each branch has `args` with that command's own schema (including `required` and `additionalProperties: false`) -- [ ] No `anyOf` at root schema level -- [ ] No use of `allOf`, `if/then/else`, `not`, `pattern`, `dependencies` -- [ ] `additionalProperties: false` is set on every object level -- [ ] Schema changes when commands are added/removed from the registry -- [ ] With no commands registered, `anyOf` is an empty array -- [ ] No-arg commands have `args: { type: "object", additionalProperties: false, properties: {} }` - -### `validateResponse(response: ExecuteGridAssistantCommandResult): boolean` - -Validates the AI response against the per-command schemas defined in each `GridCommand.schema`. Since each command has its own `anyOf` branch with explicit `required` and `additionalProperties: false`, validation checks each action's `args` against the matching command's schema. - -- `response.actions` must be an array -- Each action must have `name` (string, known command) and `args` (object, not `null`) -- Each action's `args` is validated against the matching command's `schema` (which defines its own `required` properties, allowed property types, and `additionalProperties: false`) -- Extra/unknown properties in `args` are rejected (enforced by per-command `additionalProperties: false`) -- `null` values: if `args` is `null` instead of an object, validation fails (inconsistent with schema) -- Empty string `name` (`""`) is treated as an unknown command — validation fails -- Any mismatch → return `false` (entire response rejected) - -**Acceptance criteria:** -- [ ] Returns `true` for a valid response with known command names and correct arg types -- [ ] Returns `false` if `response.actions` is not an array -- [ ] Returns `false` if `response.actions` is missing -- [ ] Returns `false` if any action has an unknown `name` -- [ ] Returns `false` if any action's `name` is not a string (e.g. `name: 123`) -- [ ] Returns `false` if any action's `name` is an empty string (`""`) -- [ ] Returns `false` if any action is missing `name` or `args` -- [ ] Returns `false` if any action's `args` is `null` (must be an object) -- [ ] Returns `false` if any action's `args` has wrong types for required properties -- [ ] Returns `false` if any action's `args` is missing required properties for that command (as defined by command's `schema.required`) -- [ ] Returns `false` if any action's `args` contains extra properties not in that command's schema -- [ ] Returns `true` for an empty `actions` array -- [ ] Returns `true` for no-arg commands when `args` is `{}` -- [ ] Rejects the entire response on first mismatch - -### `async executeCommands(commands, customizeResponseText?): Promise` - -**Precondition:** `validateResponse` must be called before `executeCommands`. If the response is invalid, `executeCommands` should not be called. However, as a defensive measure, if an unknown command name is encountered during execution, it records a `failure` result for that command and continues to the next. - -**Reentrancy guard:** `executeCommands` tracks whether it is currently executing via an internal `_executing` flag. If called while a previous execution is still in progress, it throws an error immediately (does not queue or execute). This makes the programming error explicit and impossible to silently ignore. - -**Abort support:** `_aborted` is reset to `false` only when `executeCommands` successfully starts (passes the reentrancy guard). A concurrent call rejected by the reentrancy guard does **not** reset the flag — so `abort()` called during an in-progress execution is never lost. Before each loop iteration, the method checks `_aborted`. If `true`, it pushes a `CommandResult` with `status: 'aborted'` and a default message (e.g. `'Command execution aborted'`) for the first skipped command, then breaks. The method returns the partial `CommandResult[]` containing results for already-completed commands plus one `aborted` entry. Remaining commands are not represented in results. After abort-induced exit, `_executing` is set to `false` so subsequent calls work normally. - -- Uses `this.component` (no `component` parameter) -- Resets `_aborted = false` only on actual execution start (not on reentrancy rejection) -- Iterates commands in AI-returned order -- Before each iteration, checks `_aborted`; if `true`, pushes `{ status: 'aborted', message: defaultAbortedMessage }` and breaks -- For each command: finds matching `GridCommand` by `name`, calls `execute(this.component, { success, failure })` to get executor, then calls `executor(args)` -- If command name is unknown (defensive), records `failure('Unknown command: ')` and continues -- Awaits each command before proceeding to next -- If executor throws, catches and records `failure()` -- If `customizeResponseText` is provided, applies message override per command after execution (see Message Customization section below) -- Returns `CommandResult[]` on success or abort; throws if called concurrently - -**Acceptance criteria:** -- [ ] Uses `this.component` (no `component` parameter) -- [ ] Resets `_aborted` to `false` only on actual execution start (not on reentrancy rejection) -- [ ] Executes commands in the order provided in `commands` array -- [ ] Each command is awaited before the next one starts -- [ ] Returns one `CommandResult` per executed command (plus one `aborted` entry if aborted) -- [ ] A throwing executor produces `CommandResult` with `status: 'failure'` and the error message -- [ ] An async executor that rejects produces `CommandResult` with `status: 'failure'` -- [ ] Unknown command name (defensive) produces `CommandResult` with `status: 'failure'` and message containing the command name -- [ ] Returns empty array for empty `commands` -- [ ] If called while another `executeCommands` is in progress, throws an error -- [ ] After the first call completes, subsequent calls work normally -- [ ] Commands that succeed have `status: 'success'`; commands that fail have `status: 'failure'` -- [ ] `abort()` called during execution → results contain completed commands + one `{ status: 'aborted' }` entry, then stops -- [ ] `abort()` called before first command executes → returns `[{ status: 'aborted', message: ... }]` -- [ ] `_aborted` is reset only on actual execution start, so a previous `abort()` does not affect the next successful call -- [ ] A concurrent call rejected by reentrancy guard does **not** reset `_aborted` -- [ ] `_executing` is set to `false` after abort-induced exit -- [ ] Only one `aborted` result is added (for the first skipped command); remaining commands are not represented -- [ ] Without `customizeResponseText`, all messages are defaults from command executors -- [ ] `customizeResponseText` is called once per executed command with correct `commandName` and `commandArgs` -- [ ] `customizeResponseText` returning `{ success: 'X', failure: 'Y' }` replaces the message for the matching status -- [ ] `customizeResponseText` returning `{ success: 'X' }` only replaces message when status is `'success'`; `'failure'` stays default -- [ ] `customizeResponseText` returning `undefined` leaves the default message unchanged -- [ ] `customizeResponseText` is not called for the `aborted` entry or for commands skipped by abort -- [ ] `customizeResponseText` is not called for commands that were not executed (e.g. validation failed) - -### Message Customization - -Users can provide a `customizeResponseText` callback to override default success/failure messages per command. - -#### Type - -```typescript -type CommandMessages = { - success: string; - failure: string; -}; - -type CustomizeResponseText = ( - commandName: string, - commandArgs: Record, -) => Partial | undefined; -``` - -#### Usage - -`customizeResponseText` is passed to `GridCommands` (e.g. via constructor or `executeCommands` options). For each executed command, if provided, it is called with the command name and args. The callback can: - -- Return `{ success, failure }` — overrides both messages -- Return `{ success }` or `{ failure }` — overrides only specified, keeps default for the other -- Return `undefined` — uses default messages - -> **Note:** `customizeResponseText` can override any message, including diagnostic failure messages set by the command executor (e.g. `'Column "foo" does not exist'`). This is by design — the consumer takes full responsibility for the content of overridden messages. If preserving diagnostic detail is important, the callback should return `undefined` for commands whose failure messages should not be altered. - -#### Example - -```typescript -customizeResponseText: (commandName, commandArgs) => { - switch (commandName) { - case 'filtering': - return { - success: `Successfully filtered ${commandArgs.dataField}`, - failure: `Failed to filter ${commandArgs.dataField}`, - }; - case 'sorting': { - return { - success: `Successfully sorted ${commandArgs.dataField}`, - }; - } - default: - return undefined; - } -} -``` - -#### Integration in `executeCommands` - -```typescript -// Inside GridCommands.executeCommands: -for (const { name, args } of commands) { - if (this._aborted) { - results.push({ status: 'aborted', message: defaultAbortedMessage }); - break; - } - - const executor = command.execute(this.component, callbacks); - const result = await executor(args); - - // Apply message customization - const customMessages = customizeResponseText?.(name, args); - - if (isDefined(customMessages?.[result.status])) { - result.message = customMessages[result.status]; - } - - results.push(result); -} -``` - ---- - -## Command Specifications - -### Sorting (`sorting.ts`) - -#### `sorting` - -- **Description:** Apply sorting to one or more columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, 'sortOrder', sortOrder)` for each column -- **Success message:** TODO -- **Failure message:** TODO - -#### `clearSorting` - -- **Description:** Remove all sorting -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(i, 'sortOrder', undefined)` for each column -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Filtering (`filtering.ts`) - -#### `filterValue` - -- **Description:** Apply a filter expression -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `component.option('filterValue', value)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `clearFilter` - -- **Description:** Clear all filters -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `dataController.clearFilter()` -- **Success message:** TODO -- **Failure message:** TODO - -#### `searching` - -- **Description:** Set search panel text -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `component.option('searchPanel.text', value)` -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Grouping (`grouping.ts`) - -#### `grouping` - -- **Description:** Group by one or more columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, 'groupIndex', index)` for each column -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Paging (`paging.ts`) - -#### `page` - -- **Description:** Navigate to a specific page -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `dataController.pageIndex(index)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `pageSize` - -- **Description:** Change the page size -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `dataController.pageSize(size)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `groupPaging` - -- **Description:** Navigate group paging -- **Args schema:** - ```json - TODO - ``` -- **Execute:** TODO -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Selection (`selection.ts`) - -#### `selectByKeys` - -- **Description:** Select rows by key values -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `selectionController.selectRows(keys, preserve)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `selectByIndexes` - -- **Description:** Select rows by row indexes -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `selectionController.selectRowsByIndexes(indexes)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `selectAll` - -- **Description:** Select all rows -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `selectionController.selectAll()` -- **Success message:** TODO -- **Failure message:** TODO - -#### `deselectAll` - -- **Description:** Deselect all rows -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `selectionController.deselectAll()` -- **Success message:** TODO -- **Failure message:** TODO - -#### `clearSelection` - -- **Description:** Clear selection -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `selectionController.clearSelection()` -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Columns (`columns.ts`) - -#### `columnsVisibility` - -- **Description:** Show or hide columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, 'visible', value)` for each column -- **Success message:** TODO -- **Failure message:** TODO - -#### `columnsReorder` - -- **Description:** Reorder columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, 'visibleIndex', index)` for each column -- **Success message:** TODO -- **Failure message:** TODO - -#### `columnsPinning` - -- **Description:** Pin/unpin columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, { fixed, fixedPosition })` for each column -- **Success message:** TODO -- **Failure message:** TODO - -#### `columnsResize` - -- **Description:** Resize columns -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `columnsController.columnOption(dataField, 'width', value)` for each column -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Summary (`summary.ts`) - -#### `summary` - -- **Description:** Configure summary items -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `component.option('summary', value)` -- **Success message:** TODO -- **Failure message:** TODO - -#### `clearSummary` - -- **Description:** Remove all summary items -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `component.option('summary', {})` -- **Success message:** TODO -- **Failure message:** TODO - ---- - -### Focus (`focus.ts`) - -#### `rowFocusing` - -- **Description:** Focus a specific row -- **Args schema:** - ```json - TODO - ``` -- **Execute:** `component.option('focusedRowKey', key)` + `focusController.navigateToRow(key)` -- **Success message:** TODO -- **Failure message:** TODO - ---- - -## Integration with AIAssistantIntegrationController - ```json - TODO - ``` - -In `ai_assistant_integration_controller.ts`: - -1. Instantiate `GridCommands` with `this.component` and full command list -2. `buildContext()` — implemented directly on the controller (not delegated to `gridCommands`) -3. `buildResponseSchema()` → `gridCommands.buildResponseSchema()` -4. In `onComplete` callback: - - `gridCommands.validateResponse(response)` → if `false`, return failure text - - `const results = await gridCommands.executeCommands(response.actions, customizeResponseText)` - - Use `results` directly to render response message in chat (handle `aborted` status entries appropriately) -5. In `abortRequest()`: - - Call existing `this.abort?.()` to abort LLM request - - Call `this.gridCommands?.abort()` to abort in-progress command execution -6. Add `isExecutingCommands()` method: - - Returns `this.gridCommands?.isExecuting ?? false` -7. Wire popup `onHidden` to call `abortRequest()` so closing the chat aborts both LLM and command execution - -### `buildContext(): Record` - -Implemented on `AIAssistantIntegrationController`, not on `GridCommands`. The controller already owns `this.component` and is the orchestration layer between the grid and the AI. This keeps `GridCommands` focused on schema building, validation, and execution. - -Collects current grid state from `this.component`: - -- **columns** — all columns (including hidden) with: `dataField`, `caption`, `dataType`, `visible`, `sortOrder`, `sortIndex`, `groupIndex`, `filterValue`, `fixed`, `fixedPosition`, `width`, `visibleIndex` -- **filtering** — current `filterValue`, `filterPanel` state -- **paging** — `pageIndex`, `pageSize`, `totalCount` -- **search** — current search text -- **selection** — selected keys -- **summary** — current summary configuration - -**Acceptance criteria:** -- [ ] Uses `this.component` (no `component` parameter) -- [ ] Returns an object containing all listed state categories -- [ ] `columns` includes all columns (both visible and hidden) with their `visible` flag -- [ ] `columns` includes all listed properties for each column -- [ ] `paging` reflects current `pageIndex`, `pageSize`, and `totalCount` -- [ ] `search` reflects current search panel text (empty string if none) -- [ ] `selection` reflects currently selected keys (empty array if none) -- [ ] `summary` reflects current summary configuration (empty if none) -- [ ] Context updates correctly after grid state changes - ---- - -## Out of Scope (Future Iterations) - -The following items are intentionally deferred and should be addressed when custom (user-defined) commands are supported: - -For next iteration, we might consider: -- **Structured validation errors:** `validateResponse` currently returns a bare `boolean`. For production debugging, a structured result like `{ valid: boolean; errors: ValidationError[] }` would help explain *why* validation failed. Acceptable for v1 since the controller shows a generic failure message. -- **Schema versioning:** No `schemaVersion` field or graceful degradation for unknown commands. If commands are added/removed between versions and the LLM provider caches tool definitions, stale schemas will hard-fail at validation. Low risk for v1 since `buildResponseSchema()` is called dynamically per request. -- **Mid-command abort:** Abort is only checked between commands. If a single command executor is long-running (e.g. `selectAll` on a large dataset), it cannot be interrupted mid-execution. A future iteration could pass an `AbortSignal` to `CommandExecutor` so individual commands can cooperatively check for cancellation. -- **Command executor return shape validation:** Currently, all built-in command executors are guaranteed to return a valid `CommandResult` (`{ status, message }`). When custom commands are allowed, `executeCommands` should defensively validate the executor's return value. We must agree how to treat malformed results. -- **Per-command timeouts:** No timeout mechanism for individual command executors. A misbehaving executor could hang indefinitely. -- **Actions array length limit:** No cap on the number of actions in a response. -- **No input sanitization on args.** LLM-generated args are passed directly to `component.option()` and `columnOption()`. If `dataField` contains a crafted value, could it access unintended columns or trigger injection? The spec should note that args must be validated against actual grid state (e.g., `dataField` must match an existing column). - -We consider these as excessive: -- **No rollback on partial failure.** If action 3 of 5 fails, actions 1–2 have already mutated the grid. There's no mention of whether this is acceptable or whether a transaction/rollback mechanism is needed. -- **No allowlist for option paths.** `component.option('searchPanel.text', value)` uses a string path — if this pattern is generalized, an LLM could set arbitrary options. - ---- - -## Implementation Order - -1. `types.ts` — interfaces -2. `grid_commands.ts` — class skeleton with `buildResponseSchema`, `validateResponse`, `executeCommands` -3. Tests for `GridCommands` class -4. Commands one by one (each includes schema + execute + tests): - 1. `sorting.ts` (sorting, clearSorting) - 2. `filtering.ts` (filterValue, clearFilter, searching) - 3. `grouping.ts` - 4. `paging.ts` (page, pageSize, groupPaging) - 5. `selection.ts` (selectByKeys, selectByIndexes, selectAll, deselectAll, clearSelection) - 6. `columns.ts` (columnsVisibility, columnsReorder, columnsPinning, columnsResize) - 7. `summary.ts` (summary, clearSummary) - 8. `focus.ts` (rowFocusing) -5. Wire into `AIAssistantIntegrationController` -6. Integration tests covering end-to-end flow with mocked AI responses From a77eb45400a743921a36a013438dc7e6090fb24f Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 30 Apr 2026 14:19:05 +0400 Subject: [PATCH 16/34] fix qunit tests --- .../core/ai_integration/commands/executeGridAssistant.ts | 1 + packages/devextreme/testing/runner/lib/pages.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts index 6365e4c075ec..4dea8c208a54 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts @@ -23,6 +23,7 @@ export class ExecuteGridAssistantCommand extends BaseCommand< }; } + // TODO: check response more carefully protected parseResult( response: ExecuteGridAssistantCommandResponse, ): ExecuteGridAssistantCommandResult { diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index 7abc68ac899a..a238f79fb35a 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -178,6 +178,9 @@ export function createPagesRenderer({ json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', + // eslint-disable-next-line spellcheck/spell-checker + zod: '/packages/devextreme/node_modules/zod/lib/index.mjs', + 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/zodToJsonSchema.js', ...cspMap, }; From 8a2b773e9abc53e56c264fd64dbb0dbf8ff0e003 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 30 Apr 2026 14:50:30 +0400 Subject: [PATCH 17/34] fix a response validation --- .../__tests__/ai_assistant_controller.test.ts | 12 +++ .../__tests__/grid_commands.test.ts | 102 +++++++----------- .../ai_assistant/ai_assistant_controller.ts | 2 +- .../grid_core/ai_assistant/grid_commands.ts | 14 +-- 4 files changed, 56 insertions(+), 74 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts index 3e46c2b926bd..8f4bc65977c6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -16,6 +16,12 @@ import { AI_ASSISTANT_AUTHOR_ID, MessageStatus, } from '../const'; +import { GridCommands } from '../grid_commands'; +import type { CommandResult } from '../types'; + +jest.mock('../grid_commands'); + +const MockedGridCommands = jest.mocked(GridCommands); let sendRequestCallbacks: RequestCallbacks = {}; @@ -59,6 +65,12 @@ const getStore = (controller: AIAssistantController): ArrayStore { beforeEach(() => { jest.clearAllMocks(); + + // TODO: Rework the tests using updated GridCommands implementation + MockedGridCommands.mockImplementation(() => ({ + validate: jest.fn().mockReturnValue(true), + executeCommands: jest.fn<() => Promise>().mockResolvedValue([{ status: 'success', message: 'sort' }]), + }) as unknown as GridCommands); }); describe('getMessageDataSource', () => { 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 5a97fed3ff7b..27dd9e257d70 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 @@ -339,42 +339,20 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'test', args: { value: 'hello' } }], - }); + const result = gridCommands.validate( + [{ name: 'test', args: { value: 'hello' } }], + ); expect(result).toBe(true); }); - it('should return false if response.actions is not an array', () => { - const gridCommands = new GridCommands(createMockComponent(), []); - - expect(gridCommands.validate({ actions: 'not-array' })).toBe(false); - expect(gridCommands.validate({ actions: 123 })).toBe(false); - expect(gridCommands.validate({ actions: {} })).toBe(false); - }); - - it('should return false if response.actions is missing', () => { - const gridCommands = new GridCommands(createMockComponent(), []); - - expect(gridCommands.validate({})).toBe(false); - expect(gridCommands.validate({ other: [] })).toBe(false); - }); - - it('should return false if response is null or undefined', () => { - const gridCommands = new GridCommands(createMockComponent(), []); - - expect(gridCommands.validate(null)).toBe(false); - expect(gridCommands.validate(undefined)).toBe(false); - }); - it('should return false if any action has an unknown name', () => { const command = createMockCommand('known'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'unknown', args: {} }], - }); + const result = gridCommands.validate( + [{ name: 'unknown', args: {} }], + ); expect(result).toBe(false); }); @@ -384,13 +362,13 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - expect(gridCommands.validate({ - actions: [{ name: 123, args: {} }], - })).toBe(false); + expect(gridCommands.validate( + [{ name: 123 as unknown as string, args: {} }], + )).toBe(false); - expect(gridCommands.validate({ - actions: [{ name: true, args: {} }], - })).toBe(false); + expect(gridCommands.validate( + [{ name: true as unknown as string, args: {} }], + )).toBe(false); }); it('should return false if any action name is an empty string', () => { @@ -398,9 +376,9 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - const result = gridCommands.validate({ - actions: [{ name: '', args: {} }], - }); + const result = gridCommands.validate( + [{ name: '', args: {} }], + ); expect(result).toBe(false); }); @@ -410,13 +388,13 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - expect(gridCommands.validate({ - actions: [{ args: {} }], - })).toBe(false); + expect(gridCommands.validate( + [{ args: {} } as any], + )).toBe(false); - expect(gridCommands.validate({ - actions: [{ name: 'test' }], - })).toBe(false); + expect(gridCommands.validate( + [{ name: 'test' } as any], + )).toBe(false); }); it('should return false if any action args is null', () => { @@ -424,9 +402,9 @@ describe('GridCommands', () => { createMockCommand('test'), ]); - const result = gridCommands.validate({ - actions: [{ name: 'test', args: null }], - }); + const result = gridCommands.validate( + [{ name: 'test', args: null as unknown as Record }], + ); expect(result).toBe(false); }); @@ -437,9 +415,9 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'test', args: { value: 123 } }], - }); + const result = gridCommands.validate( + [{ name: 'test', args: { value: 123 } }], + ); expect(result).toBe(false); }); @@ -450,9 +428,9 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'test', args: {} }], - }); + const result = gridCommands.validate( + [{ name: 'test', args: {} }], + ); expect(result).toBe(false); }); @@ -463,9 +441,9 @@ describe('GridCommands', () => { }); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'test', args: { value: 'ok', extra: true } }], - }); + const result = gridCommands.validate( + [{ name: 'test', args: { value: 'ok', extra: true } }], + ); expect(result).toBe(false); }); @@ -473,7 +451,7 @@ describe('GridCommands', () => { it('should return true for an empty actions array', () => { const gridCommands = new GridCommands(createMockComponent(), []); - const result = gridCommands.validate({ actions: [] }); + const result = gridCommands.validate([]); expect(result).toBe(true); }); @@ -482,9 +460,9 @@ describe('GridCommands', () => { const command = createMockCommand('clearFilter'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [{ name: 'clearFilter', args: {} }], - }); + const result = gridCommands.validate( + [{ name: 'clearFilter', args: {} }], + ); expect(result).toBe(true); }); @@ -493,12 +471,12 @@ describe('GridCommands', () => { const command = createMockCommand('valid'); const gridCommands = new GridCommands(createMockComponent(), [command]); - const result = gridCommands.validate({ - actions: [ + const result = gridCommands.validate( + [ { name: 'valid', args: {} }, { name: 'invalid', args: {} }, ], - }); + ); expect(result).toBe(false); }); 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 a4de95b3e8c2..d7adb1ffe996 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 @@ -33,7 +33,7 @@ export class AIAssistantController extends Controller { } private processResponse(response: ExecuteGridAssistantCommandResult): Promise { - if (!response?.actions) { + if (!response?.actions || !Array.isArray(response.actions)) { // TODO: need to localize default error message when there are no commands return Promise.reject(new Error('Default error message')); } 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 9f0797e0b803..75a74313ae54 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 @@ -1,6 +1,4 @@ -import type { - ExecuteGridAssistantAction, -} from '@js/common/ai-integration'; +import type { ExecuteGridAssistantAction } from '@js/common/ai-integration'; import messageLocalization from '@js/common/core/localization/message'; import { isDefined, isObject } from '@js/core/utils/type'; import { zodToJsonSchema } from 'zod-to-json-schema'; @@ -118,14 +116,8 @@ export class GridCommands { }; } - public validate(response: unknown): boolean { - const res = response as Record; - - if (!res || !Array.isArray(res.actions)) { - return false; - } - - for (const action of res.actions as Record[]) { + public validate(actions: ExecuteGridAssistantAction[]): boolean { + for (const action of actions as Record[]) { if (!action || typeof action.name !== 'string' || action.name === '') { return false; } From 82db686b12c565c5acdcfe7ad8fbaf0407d6b3ec Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 30 Apr 2026 14:57:45 +0400 Subject: [PATCH 18/34] fix type error and zod-to-json-schema import path --- .../__tests__/ai_assistant_controller.test.ts | 13 ++++++++----- packages/devextreme/testing/runner/lib/pages.ts | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts index 8f4bc65977c6..5d5987d445dc 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -21,7 +21,7 @@ import type { CommandResult } from '../types'; jest.mock('../grid_commands'); -const MockedGridCommands = jest.mocked(GridCommands); +const MockedGridCommands = GridCommands as jest.MockedClass; let sendRequestCallbacks: RequestCallbacks = {}; @@ -67,10 +67,13 @@ describe('AIAssistantController', () => { jest.clearAllMocks(); // TODO: Rework the tests using updated GridCommands implementation - MockedGridCommands.mockImplementation(() => ({ - validate: jest.fn().mockReturnValue(true), - executeCommands: jest.fn<() => Promise>().mockResolvedValue([{ status: 'success', message: 'sort' }]), - }) as unknown as GridCommands); + (MockedGridCommands.mockImplementation as jest.Mock).call( + MockedGridCommands, + () => ({ + validate: jest.fn().mockReturnValue(true), + executeCommands: jest.fn<() => Promise>().mockResolvedValue([{ status: 'success', message: 'sort' }]), + }), + ); }); describe('getMessageDataSource', () => { diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index a238f79fb35a..437e766cd54a 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -180,7 +180,7 @@ export function createPagesRenderer({ 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', // eslint-disable-next-line spellcheck/spell-checker zod: '/packages/devextreme/node_modules/zod/lib/index.mjs', - 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/zodToJsonSchema.js', + 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/esm/zodToJsonSchema.js', ...cspMap, }; From 186d370091921314f7a0bd7b346aef039a8dbb21 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 30 Apr 2026 16:48:33 +0400 Subject: [PATCH 19/34] fix zod import path for qunit tests --- packages/devextreme/testing/runner/lib/pages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index 437e766cd54a..21b3e73644c8 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -179,8 +179,8 @@ export function createPagesRenderer({ 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', // eslint-disable-next-line spellcheck/spell-checker - zod: '/packages/devextreme/node_modules/zod/lib/index.mjs', - 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/esm/zodToJsonSchema.js', + zod: '/packages/devextreme/node_modules/zod/lib/index.js', + 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/zodToJsonSchema.js', ...cspMap, }; From eea45266df0d690eb2d3ebad0ac8189c20779551 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 30 Apr 2026 19:11:16 +0400 Subject: [PATCH 20/34] fix import path and format for zod and zod-to-json-schema --- .../devextreme/testing/runner/lib/pages.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index 21b3e73644c8..487577c4e3f4 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -179,12 +179,16 @@ export function createPagesRenderer({ 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', // eslint-disable-next-line spellcheck/spell-checker - zod: '/packages/devextreme/node_modules/zod/lib/index.js', - 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/zodToJsonSchema.js', + zod: '/packages/devextreme/node_modules/zod/lib', + 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs', ...cspMap, }; - const systemPackages: Record = { + const systemPackages: Record = { '': { defaultExtension: 'js', }, @@ -205,6 +209,17 @@ export function createPagesRenderer({ events: { main: 'index', }, + // eslint-disable-next-line spellcheck/spell-checker + zod: { + main: 'index.js', + defaultExtension: 'js', + format: 'cjs', + }, + 'zod-to-json-schema': { + main: 'index.js', + defaultExtension: 'js', + format: 'cjs', + }, }; const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; From 06a62036059ab4322df87b73cbe71f13cbe57fd6 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 5 May 2026 16:34:19 +0200 Subject: [PATCH 21/34] fix qunit meta --- packages/devextreme/testing/runner/lib/pages.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index 487577c4e3f4..f2a57050532e 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -238,6 +238,12 @@ export function createPagesRenderer({ deps: ['jquery'], exports: 'ko', }, + '/packages/devextreme/node_modules/zod/lib/*.js': { + format: 'cjs', + }, + '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/*.js': { + format: 'cjs', + }, '*.js': { babelOptions: { es2015: false, From 15c301fdce818516f531674047f6848eea2d38db Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 5 May 2026 17:18:12 +0200 Subject: [PATCH 22/34] try zod stabs for qunit configuration --- .../helpers/qunit-stubs/zod-to-json-schema/index.js | 5 +++++ .../testing/helpers/qunit-stubs/zod/index.js | 7 +++++++ packages/devextreme/testing/runner/lib/pages.ts | 11 +++++------ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js create mode 100644 packages/devextreme/testing/helpers/qunit-stubs/zod/index.js diff --git a/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js b/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js new file mode 100644 index 000000000000..35f06c9b64db --- /dev/null +++ b/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js @@ -0,0 +1,5 @@ +define(function(require, exports, module) { + // eslint-disable-next-line spellcheck/spell-checker + exports.zodToJsonSchema = function() { return {}; }; + Object.defineProperty(exports, '__esModule', { value: true }); +}); diff --git a/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js b/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js new file mode 100644 index 000000000000..73049a88c7fb --- /dev/null +++ b/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js @@ -0,0 +1,7 @@ +define(function(require, exports, module) { + function callable() { return proxy; } + // eslint-disable-next-line no-var + var proxy = new Proxy(callable, { get: function() { return proxy; } }); + exports.z = proxy; + Object.defineProperty(exports, '__esModule', { value: true }); +}); diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index f2a57050532e..7431e2e37440 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -137,6 +137,11 @@ export function createPagesRenderer({ 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', json: '/packages/devextreme/artifacts/js-systemjs/json.js', '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', + // QUnit doesn't exercise AI assistant; CSP-production SystemJS can't load CJS. + // Stub zod and zod-to-json-schema so DataGrid module loading succeeds. + // eslint-disable-next-line spellcheck/spell-checker + zod: '/packages/devextreme/artifacts/transpiled-testing/helpers/qunit-stubs/zod', + 'zod-to-json-schema': '/packages/devextreme/artifacts/transpiled-testing/helpers/qunit-stubs/zod-to-json-schema', } : { 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', @@ -238,12 +243,6 @@ export function createPagesRenderer({ deps: ['jquery'], exports: 'ko', }, - '/packages/devextreme/node_modules/zod/lib/*.js': { - format: 'cjs', - }, - '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs/*.js': { - format: 'cjs', - }, '*.js': { babelOptions: { es2015: false, From eb12ddadb61e97efb839dedffc97796d929063ba Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 06:50:35 +0400 Subject: [PATCH 23/34] Add todo comment and move constants to suitable file --- .../ai_assistant/ai_assistant_controller.ts | 1 + .../grids/grid_core/ai_assistant/const.ts | 4 ++++ .../grids/grid_core/ai_assistant/grid_commands.ts | 15 ++++++--------- 3 files changed, 11 insertions(+), 9 deletions(-) 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 d7adb1ffe996..6c04e77c6776 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 @@ -87,6 +87,7 @@ export class AIAssistantController extends Controller { } public init(): void { + // TODO: initialize default commands list when they are ready this.gridCommands = new GridCommands(this.component, []); this.messageStore = new ArrayStore({ key: 'id', diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts index e0fb7442fda5..b70706e300d6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts @@ -15,3 +15,7 @@ export enum MessageStatus { Success = 'success', Failure = 'failure', } + +export const DEFAULT_SUCCESS_MESSAGE = 'dxDataGrid-aiAssistantSuccessMessage'; +export const DEFAULT_FAILURE_MESSAGE = 'dxDataGrid-aiAssistantErrorMessage'; +export const EXECUTION_ABORT_MESSAGE = 'dxDataGrid-aiAssistantExecutionAbortMessage'; 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 75a74313ae54..173b30320b8b 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 @@ -1,21 +1,18 @@ import type { ExecuteGridAssistantAction } from '@js/common/ai-integration'; import messageLocalization from '@js/common/core/localization/message'; import { isDefined, isObject } from '@js/core/utils/type'; +import { + DEFAULT_FAILURE_MESSAGE, + DEFAULT_SUCCESS_MESSAGE, + EXECUTION_ABORT_MESSAGE, +} from '@ts/grids/grid_core/ai_assistant/const'; import { zodToJsonSchema } from 'zod-to-json-schema'; import type { InternalGrid } from '../m_types'; import type { - CommandCallbacks, - CommandResult, - CustomizeResponseText, - GridCommand, - JsonSchema, + CommandCallbacks, CommandResult, CustomizeResponseText, GridCommand, JsonSchema, } from './types'; -const DEFAULT_SUCCESS_MESSAGE = 'dxDataGrid-aiAssistantSuccessMessage'; -const DEFAULT_FAILURE_MESSAGE = 'dxDataGrid-aiAssistantErrorMessage'; -const EXECUTION_ABORT_MESSAGE = 'dxDataGrid-aiAssistantExecutionAbortMessage'; - export class GridCommands { private readonly component: InternalGrid; From 7ebcf0d2b3f6b4fe0cbd1f0a13a8a9779680d15a Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 06:54:07 +0400 Subject: [PATCH 24/34] Add todo comment and remove unnecessary underscore from private props --- .../grid_core/ai_assistant/grid_commands.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 173b30320b8b..c1a6a76540f9 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,11 +16,12 @@ import type { export class GridCommands { private readonly component: InternalGrid; + // TODO: specify type of command arguments when default commands are implemented private readonly commands: Map>>; - private _executing = false; + private executing = false; - private _aborted = false; + private aborted = false; constructor(component: InternalGrid, commands: GridCommand[]) { this.component = component; @@ -63,15 +64,15 @@ export class GridCommands { } public abort(): void { - this._aborted = true; + this.aborted = true; } public isAborted(): boolean { - return this._aborted; + return this.aborted; } public isExecuting(): boolean { - return this._executing; + return this.executing; } public buildResponseSchema(): JsonSchema { @@ -157,12 +158,12 @@ export class GridCommands { commands: ExecuteGridAssistantAction[], customizeResponseText?: CustomizeResponseText, ): Promise { - if (this._executing) { + if (this.executing) { throw new Error('executeCommands is already in progress'); } - this._executing = true; - this._aborted = false; + this.executing = true; + this.aborted = false; const results: CommandResult[] = []; const callbacks: CommandCallbacks = { @@ -171,7 +172,7 @@ export class GridCommands { }; for (const { name, args } of commands) { - if (this._aborted) { + if (this.aborted) { results.push({ status: 'aborted', message: messageLocalization.format(EXECUTION_ABORT_MESSAGE), @@ -182,7 +183,7 @@ export class GridCommands { const command = this.commands.get(name); if (!command) { - this._executing = false; + this.executing = false; throw new Error(`Unknown command: ${name}`); } // eslint-disable-next-line no-await-in-loop @@ -192,7 +193,7 @@ export class GridCommands { results.push(result); } - this._executing = false; + this.executing = false; return results; } From 483c4ee619339c2531d0cf5e1f100267b367711a Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 07:05:01 +0400 Subject: [PATCH 25/34] Show a duplication error in console instead of throwing it --- .../ai_assistant/__tests__/grid_commands.test.ts | 10 ++++++---- .../grids/grid_core/ai_assistant/grid_commands.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) 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 27dd9e257d70..6d2182ddeaec 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 @@ -100,14 +100,16 @@ describe('GridCommands', () => { expect(commandNames).toEqual(['commandA', 'commandB']); }); - it('should throw if duplicate command names are provided', () => { + it('should log a console error if duplicate command names are provided', () => { const component = createMockComponent(); const command1 = createMockCommand('duplicate'); const command2 = createMockCommand('duplicate'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // eslint-disable-next-line no-new + new GridCommands(component, [command1, command2]); - expect( - () => new GridCommands(component, [command1, command2]), - ).toThrow('Duplicate command name: "duplicate"'); + expect(consoleSpy).toHaveBeenCalledWith('Duplicate command name: "duplicate"'); + consoleSpy.mockRestore(); }); }); 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 c1a6a76540f9..68a95202cdea 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 @@ -29,7 +29,7 @@ export class GridCommands { for (const command of commands) { if (this.commands.has(command.name)) { - throw new Error(`Duplicate command name: "${command.name}"`); + console.error(`Duplicate command name: "${command.name}"`); } this.commands.set(command.name, command); } From b475e0cd00b8c8669c222d1f0d966feeb6aaaa89 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 07:06:50 +0400 Subject: [PATCH 26/34] Combine conditions --- .../grids/grid_core/ai_assistant/grid_commands.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 68a95202cdea..1edb0a2b4f3b 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 @@ -122,11 +122,7 @@ export class GridCommands { const command = this.commands.get(action.name); - if (!command) { - return false; - } - - if (!isDefined(action.args) || !isObject(action.args)) { + if (!command || !isDefined(action.args) || !isObject(action.args)) { return false; } From 87c7ed9c583785afc23717d67d3c7dbbd7d05e4f Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 07:15:20 +0400 Subject: [PATCH 27/34] Add clarifying comments --- .../__internal/grids/grid_core/ai_assistant/grid_commands.ts | 3 +++ 1 file changed, 3 insertions(+) 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 1edb0a2b4f3b..58bbbf3b1d02 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 @@ -137,6 +137,7 @@ export class GridCommands { } private async executeCommand( + // TODO: specify type when default commands are implemented command: GridCommand>, args: Record, callbacks: CommandCallbacks, @@ -178,6 +179,8 @@ export class GridCommands { const command = this.commands.get(name); + // Ideally, this case should never happen since the validation is + // performed beforehand, but it's better to handle it for future-proofing. if (!command) { this.executing = false; throw new Error(`Unknown command: ${name}`); From e748e5ea0e2551d46709a3fc6798573cfa21209c Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 07:44:33 +0400 Subject: [PATCH 28/34] move execution flag reset into 'finally' block --- .../grid_core/ai_assistant/grid_commands.ts | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) 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 58bbbf3b1d02..aa2fe89b17c0 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 @@ -168,32 +168,33 @@ export class GridCommands { failure: GridCommands.failure, }; - for (const { name, args } of commands) { - if (this.aborted) { - results.push({ - status: 'aborted', - message: messageLocalization.format(EXECUTION_ABORT_MESSAGE), - }); - break; - } - - const command = this.commands.get(name); - - // Ideally, this case should never happen since the validation is - // performed beforehand, but it's better to handle it for future-proofing. - if (!command) { - this.executing = false; - throw new Error(`Unknown command: ${name}`); + try { + for (const { name, args } of commands) { + if (this.aborted) { + results.push({ + status: 'aborted', + message: messageLocalization.format(EXECUTION_ABORT_MESSAGE), + }); + break; + } + + const command = this.commands.get(name); + + // Ideally, this case should never happen since the validation is + // performed beforehand, but it's better to handle it for future-proofing. + if (!command) { + throw new Error(`Unknown command: ${name}`); + } + // eslint-disable-next-line no-await-in-loop + const result = await this.executeCommand(command, args, callbacks); + + GridCommands.applyCustomizedResponseText(result, name, args, customizeResponseText); + results.push(result); } - // eslint-disable-next-line no-await-in-loop - const result = await this.executeCommand(command, args, callbacks); - - GridCommands.applyCustomizedResponseText(result, name, args, customizeResponseText); - results.push(result); + } finally { + this.executing = false; } - this.executing = false; - return results; } } From 98d0990121f649b26dbbed31cf9e43427aa496ec Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 08:31:42 +0400 Subject: [PATCH 29/34] simplify systemjs config for qunit tests --- .../qunit-stubs/zod-to-json-schema/index.js | 5 --- .../testing/helpers/qunit-stubs/zod/index.js | 7 ---- .../devextreme/testing/runner/lib/pages.ts | 32 ++++++------------- 3 files changed, 9 insertions(+), 35 deletions(-) delete mode 100644 packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js delete mode 100644 packages/devextreme/testing/helpers/qunit-stubs/zod/index.js diff --git a/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js b/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js deleted file mode 100644 index 35f06c9b64db..000000000000 --- a/packages/devextreme/testing/helpers/qunit-stubs/zod-to-json-schema/index.js +++ /dev/null @@ -1,5 +0,0 @@ -define(function(require, exports, module) { - // eslint-disable-next-line spellcheck/spell-checker - exports.zodToJsonSchema = function() { return {}; }; - Object.defineProperty(exports, '__esModule', { value: true }); -}); diff --git a/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js b/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js deleted file mode 100644 index 73049a88c7fb..000000000000 --- a/packages/devextreme/testing/helpers/qunit-stubs/zod/index.js +++ /dev/null @@ -1,7 +0,0 @@ -define(function(require, exports, module) { - function callable() { return proxy; } - // eslint-disable-next-line no-var - var proxy = new Proxy(callable, { get: function() { return proxy; } }); - exports.z = proxy; - Object.defineProperty(exports, '__esModule', { value: true }); -}); diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index 7431e2e37440..011a7817d6c4 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -2,6 +2,11 @@ import { BaseRunProps, RunAllModel, RunSuiteModel, TemplateVars, } from './types'; +interface SystemPackage { + main?: string; + defaultExtension?: string; +} + interface PagesRendererDeps { contentWithCacheBuster: (contentPath: string, cacheBuster: string) => string; getCacheBuster: (searchParams: URLSearchParams) => string; @@ -137,11 +142,6 @@ export function createPagesRenderer({ 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', json: '/packages/devextreme/artifacts/js-systemjs/json.js', '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', - // QUnit doesn't exercise AI assistant; CSP-production SystemJS can't load CJS. - // Stub zod and zod-to-json-schema so DataGrid module loading succeeds. - // eslint-disable-next-line spellcheck/spell-checker - zod: '/packages/devextreme/artifacts/transpiled-testing/helpers/qunit-stubs/zod', - 'zod-to-json-schema': '/packages/devextreme/artifacts/transpiled-testing/helpers/qunit-stubs/zod-to-json-schema', } : { 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', @@ -183,17 +183,14 @@ export function createPagesRenderer({ json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', + // QUnit doesn't execute DataGrid AI assistant // eslint-disable-next-line spellcheck/spell-checker - zod: '/packages/devextreme/node_modules/zod/lib', - 'zod-to-json-schema': '/packages/devextreme/node_modules/zod-to-json-schema/dist/cjs', + zod: '@empty', + 'zod-to-json-schema': '@empty', ...cspMap, }; - const systemPackages: Record = { + const systemPackages: Record = { '': { defaultExtension: 'js', }, @@ -214,17 +211,6 @@ export function createPagesRenderer({ events: { main: 'index', }, - // eslint-disable-next-line spellcheck/spell-checker - zod: { - main: 'index.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'zod-to-json-schema': { - main: 'index.js', - defaultExtension: 'js', - format: 'cjs', - }, }; const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; From 9a93d2ac00181d2d6fd9d2c1bdc6585e214bed80 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 09:33:52 +0400 Subject: [PATCH 30/34] add zod and zod-to-json-schema dependencies to e2e tests --- e2e/wrappers/package.json | 2 ++ pnpm-lock.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/e2e/wrappers/package.json b/e2e/wrappers/package.json index 4d11582b3eb9..01e43cf8cbe3 100644 --- a/e2e/wrappers/package.json +++ b/e2e/wrappers/package.json @@ -49,6 +49,8 @@ "tslib": "^2.3.0", "vue": "^3.5.13", "vue-router": "^4.3.0", + "zod": "3.24.4", + "zod-to-json-schema": "3.24.6", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 123508311aaa..fa0384c0b939 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1275,6 +1275,12 @@ importers: vue-router: specifier: ^4.3.0 version: 4.6.4(vue@3.5.32(typescript@5.8.3)) + zod: + specifier: 3.24.4 + version: 3.24.4 + zod-to-json-schema: + specifier: 3.24.6 + version: 3.24.6(zod@3.24.4) zone.js: specifier: ~0.15.0 version: 0.15.1 From 0ccacf231aa0270dfe127bf9cc5a950189b21342 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 14:49:35 +0400 Subject: [PATCH 31/34] add customizeResponseText as second argument --- .../grids/grid_core/ai_assistant/ai_assistant_controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 6c04e77c6776..4a1d53263a2f 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 @@ -43,7 +43,11 @@ export class AIAssistantController extends Controller { return Promise.reject(new Error('Received invalid commands')); } - return this.gridCommands?.executeCommands(response.actions) ?? Promise.reject(new Error('Grid commands not initialized')); + // @ts-expect-error TODO: remove when d.ts is updated + const { customizeResponseText } = this.option('aiAssistant'); + + return this.gridCommands?.executeCommands(response.actions, customizeResponseText) + ?? Promise.reject(new Error('Grid commands not initialized')); } private createPendingAIMessage(message: Message): string { From 899775dcd30730aa2ac854241571a061228dfaad Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 15:27:41 +0400 Subject: [PATCH 32/34] replace console with logger --- .../__internal/grids/grid_core/ai_assistant/grid_commands.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 aa2fe89b17c0..29e65d0421d5 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 @@ -1,6 +1,7 @@ import type { ExecuteGridAssistantAction } from '@js/common/ai-integration'; import messageLocalization from '@js/common/core/localization/message'; import { isDefined, isObject } from '@js/core/utils/type'; +import { logger } from '@ts/core/utils/m_console'; import { DEFAULT_FAILURE_MESSAGE, DEFAULT_SUCCESS_MESSAGE, @@ -29,7 +30,7 @@ export class GridCommands { for (const command of commands) { if (this.commands.has(command.name)) { - console.error(`Duplicate command name: "${command.name}"`); + logger.error(`Duplicate command name: "${command.name}"`); } this.commands.set(command.name, command); } @@ -146,7 +147,7 @@ export class GridCommands { const executor = command.execute(this.component, callbacks); return await executor(args); } catch (e: unknown) { - console.error(`Error executing command "${command.name}":`, e); + logger.error(`Error executing command "${command.name}":`, e); return GridCommands.failure(); } } From 95256beea6821c27eefbf6407d83dad9bbbe0e69 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 16:04:34 +0400 Subject: [PATCH 33/34] fix tests --- .../__tests__/grid_commands.test.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 6d2182ddeaec..e10bb06864e3 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 { logger } from '@ts/core/utils/m_console'; import { z } from 'zod'; import type { InternalGrid } from '../../m_types'; @@ -104,12 +105,12 @@ describe('GridCommands', () => { const component = createMockComponent(); const command1 = createMockCommand('duplicate'); const command2 = createMockCommand('duplicate'); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); // eslint-disable-next-line no-new new GridCommands(component, [command1, command2]); - expect(consoleSpy).toHaveBeenCalledWith('Duplicate command name: "duplicate"'); - consoleSpy.mockRestore(); + expect(loggerSpy).toHaveBeenCalledWith('Duplicate command name: "duplicate"'); + loggerSpy.mockRestore(); }); }); @@ -605,6 +606,24 @@ describe('GridCommands', () => { expect(results[0].status).toBe('failure'); }); + 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; + }, + }); + const gridCommands = new GridCommands(createMockComponent(), [command]); + const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); + + await gridCommands.executeCommands([ + { name: 'failing', args: {} }, + ]); + + expect(loggerSpy).toHaveBeenCalledWith('Error executing command "failing":', error); + loggerSpy.mockRestore(); + }); + it('should throw for unknown command name', async () => { const gridCommands = new GridCommands(createMockComponent(), [ createMockCommand('known'), From 305e2f1b3a93366159f40d0f64dcd12df9a76004 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 6 May 2026 17:25:40 +0400 Subject: [PATCH 34/34] simplified option retrieval --- .../grids/grid_core/ai_assistant/ai_assistant_controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 4a1d53263a2f..cad4dddaeb5b 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 @@ -43,8 +43,7 @@ export class AIAssistantController extends Controller { return Promise.reject(new Error('Received invalid commands')); } - // @ts-expect-error TODO: remove when d.ts is updated - const { customizeResponseText } = this.option('aiAssistant'); + const customizeResponseText = this.option('aiAssistant.customizeResponseText'); return this.gridCommands?.executeCommands(response.actions, customizeResponseText) ?? Promise.reject(new Error('Grid commands not initialized'));