diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.test.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.test.ts new file mode 100644 index 00000000..707318c1 --- /dev/null +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.test.ts @@ -0,0 +1,230 @@ +import { createMockApiClient } from './test-utils/mock-api-client'; +import { ChangeItemColumnValuesBatchTool } from './change-item-column-values-batch-tool'; + +describe('ChangeItemColumnValuesBatchTool', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = createMockApiClient(); + jest.clearAllMocks(); + }); + + describe('successful batch updates', () => { + it('sends a single GraphQL request with aliased mutations and returns per-item results', async () => { + mocks.setResponse({ + item_0: { id: '101', name: 'Item A', url: 'https://monday.com/101' }, + item_1: { id: '102', name: 'Item B', url: 'https://monday.com/102' }, + item_2: { id: '103', name: 'Item C', url: 'https://monday.com/103' }, + }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + const result = await tool.execute({ + items: [ + { itemId: 101, columnValues: '{"status":{"label":"Done"}}' }, + { itemId: 102, columnValues: '{"status":{"label":"Done"}}' }, + { itemId: 103, columnValues: '{"status":{"label":"Done"}}' }, + ], + }); + + const content = result.content as Record; + expect(content.successful).toHaveLength(3); + expect(content.failed).toHaveLength(0); + expect(content.message).toContain('3 of 3'); + expect(mocks.getMockRequest()).toHaveBeenCalledTimes(1); + }); + + it('returns per-item fields matching the singular tool response shape', async () => { + mocks.setResponse({ + item_0: { id: '101', name: 'Item A', url: 'https://monday.com/101' }, + }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + const result = await tool.execute({ + items: [{ itemId: 101, columnValues: '{"status":{"label":"Done"}}' }], + }); + + const content = result.content as Record; + const item = content.successful[0]; + expect(item).toEqual({ + itemId: 101, + message: 'Item 101 successfully updated', + item_id: '101', + item_name: 'Item A', + item_url: 'https://monday.com/101', + }); + }); + + it('builds query with correct aliased mutation structure', async () => { + mocks.setResponse({ + item_0: { id: '101', name: 'Item A', url: null }, + }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + await tool.execute({ + items: [{ itemId: 101, columnValues: '{"status":{"label":"Done"}}' }], + }); + + const [query, variables] = mocks.getMockRequest().mock.calls[0]; + expect(query).toContain('item_0: change_multiple_column_values'); + expect(query).toContain('board_id: $boardId'); + expect(query).toContain('item_id: $itemId_0'); + expect(query).toContain('column_values: $columnValues_0'); + expect(variables).toEqual( + expect.objectContaining({ boardId: '456', itemId_0: '101', columnValues_0: '{"status":{"label":"Done"}}' }), + ); + }); + }); + + describe('partial failure handling', () => { + it('reports per-item success and failure from GraphQL partial response', async () => { + const error = new Error('invalid value - unable to assign person with id: 3477320'); + (error as any).response = { + data: { + item_0: { id: '101', name: 'Item A', url: 'https://monday.com/101' }, + item_1: null, + item_2: { id: '103', name: 'Item C', url: 'https://monday.com/103' }, + }, + errors: [{ message: 'invalid value - unable to assign person with id: 3477320', path: ['item_1'] }], + }; + mocks.mockRequest.mockRejectedValue(error); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + const result = await tool.execute({ + items: [ + { itemId: 101, columnValues: '{"person":{"personsAndTeams":[{"id":1}]}}' }, + { itemId: 102, columnValues: '{"person":{"personsAndTeams":[{"id":3477320}]}}' }, + { itemId: 103, columnValues: '{"person":{"personsAndTeams":[{"id":1}]}}' }, + ], + }); + + const content = result.content as Record; + expect(content.successful).toHaveLength(2); + expect(content.successful[0].item_id).toBe('101'); + expect(content.successful[0].message).toBe('Item 101 successfully updated'); + expect(content.failed).toHaveLength(1); + expect(content.failed[0].error).toContain('unable to assign person'); + expect(content.message).toContain('2 of 3'); + expect(mocks.getMockRequest()).toHaveBeenCalledTimes(1); + }); + + it('handles all items failing when no data returned', async () => { + mocks.setError('Board not found'); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 999 }); + + const result = await tool.execute({ + items: [ + { itemId: 101, columnValues: '{"status":{"label":"Done"}}' }, + { itemId: 102, columnValues: '{"status":{"label":"Done"}}' }, + ], + }); + + const content = result.content as Record; + expect(content.successful).toHaveLength(0); + expect(content.failed).toHaveLength(2); + expect(content.failed[0].itemId).toBe(101); + expect(content.failed[1].itemId).toBe(102); + expect(content.message).toContain('0 of 2'); + }); + + it('handles all items failing with partial data (all null)', async () => { + const error = new Error('GraphQL Error'); + (error as any).response = { + data: { item_0: null, item_1: null }, + errors: [ + { message: 'Invalid column value', path: ['item_0'] }, + { message: 'Column not found', path: ['item_1'] }, + ], + }; + mocks.mockRequest.mockRejectedValue(error); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + const result = await tool.execute({ + items: [ + { itemId: 101, columnValues: '{"status":{"label":"Bad"}}' }, + { itemId: 102, columnValues: '{"status":{"label":"Bad"}}' }, + ], + }); + + const content = result.content as Record; + expect(content.successful).toHaveLength(0); + expect(content.failed).toHaveLength(2); + expect(content.failed[0].error).toBe('Invalid column value'); + expect(content.failed[1].error).toBe('Column not found'); + }); + }); + + describe('boardId resolution', () => { + it('uses boardId from context when available', async () => { + mocks.setResponse({ item_0: { id: '101', name: 'Item A', url: 'https://monday.com/101' } }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + await tool.execute({ + items: [{ itemId: 101, columnValues: '{"status":{"label":"Done"}}' }], + }); + + expect(mocks.getMockRequest()).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ boardId: '456' }), + ); + }); + + it('uses boardId from input when no context', async () => { + mocks.setResponse({ item_0: { id: '101', name: 'Item A', url: 'https://monday.com/101' } }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient); + + await tool.execute({ + boardId: 789, + items: [{ itemId: 101, columnValues: '{"status":{"label":"Done"}}' }], + }); + + expect(mocks.getMockRequest()).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ boardId: '789' }), + ); + }); + + it('omits boardId from schema when context provides it', () => { + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + const schema = tool.getInputSchema(); + expect(schema).not.toHaveProperty('boardId'); + }); + + it('includes boardId in schema when no context', () => { + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient); + const schema = tool.getInputSchema(); + expect(schema).toHaveProperty('boardId'); + }); + }); + + describe('createLabelsIfMissing', () => { + it('includes createLabelsIfMissing variables per-item in the batch query', async () => { + mocks.setResponse({ + item_0: { id: '101', name: 'Item A', url: null }, + item_1: { id: '102', name: 'Item B', url: null }, + }); + + const tool = new ChangeItemColumnValuesBatchTool(mocks.mockApiClient, { boardId: 456 }); + + await tool.execute({ + items: [ + { itemId: 101, columnValues: '{"status":{"label":"New Label"}}', createLabelsIfMissing: true }, + { itemId: 102, columnValues: '{"status":{"label":"Existing"}}' }, + ], + }); + + const [query, variables] = mocks.getMockRequest().mock.calls[0]; + expect(query).toContain('create_labels_if_missing: $createLabelsIfMissing_0'); + expect(query).not.toContain('$createLabelsIfMissing_1'); + expect(variables.createLabelsIfMissing_0).toBe(true); + expect(variables).not.toHaveProperty('createLabelsIfMissing_1'); + }); + }); +}); diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.ts new file mode 100644 index 00000000..632992db --- /dev/null +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/change-item-column-values-batch-tool.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import { ToolInputType, ToolOutputType, ToolType } from '../../tool'; +import { BaseMondayApiTool, createMondayApiAnnotations } from './base-monday-api-tool'; + +const batchItemSchema = z.object({ + itemId: z.number().describe('The ID of the item to be updated'), + columnValues: z + .string() + .describe( + `A string containing the new column values for the item following this structure: {\\"column_id\\": \\"value\\",... you can change multiple columns at once, note that for status column you must use nested value with 'label' as a key and for date column use 'date' as key} - example: "{\\"text_column_id\\":\\"New text\\", \\"status_column_id\\":{\\"label\\":\\"Done\\"}, \\"date_column_id\\":{\\"date\\":\\"2023-05-25\\"}, \\"phone_id\\":\\"123-456-7890\\", \\"email_id\\":\\"test@example.com\\"}"`, + ), + createLabelsIfMissing: z + .boolean() + .optional() + .describe( + 'If true, create missing Status/Dropdown labels when setting those columns. Requires permission to change board structure. Omit or false to only use existing labels.', + ), +}); + +export const changeItemColumnValuesBatchToolSchema = { + items: z + .array(batchItemSchema) + .min(1) + .max(50) + .describe('Array of items to update. Each item needs an itemId and columnValues. Max 50 items per batch.'), +}; + +export const changeItemColumnValuesBatchInBoardToolSchema = { + boardId: z.number().describe('The ID of the board containing the items to update'), + ...changeItemColumnValuesBatchToolSchema, +}; + +export type ChangeItemColumnValuesBatchToolInput = + | typeof changeItemColumnValuesBatchToolSchema + | typeof changeItemColumnValuesBatchInBoardToolSchema; + +type ItemResult = { id: string; name: string; url: string | null } | null; + +function buildBatchMutation(boardId: string, items: z.infer[]) { + const varDefs: string[] = ['$boardId: ID!']; + const mutations: string[] = []; + const variables: Record = { boardId }; + + items.forEach((item, i) => { + varDefs.push(`$itemId_${i}: ID!`, `$columnValues_${i}: JSON!`); + variables[`itemId_${i}`] = item.itemId.toString(); + variables[`columnValues_${i}`] = item.columnValues; + + const args = ['board_id: $boardId', `item_id: $itemId_${i}`, `column_values: $columnValues_${i}`]; + + if (item.createLabelsIfMissing !== undefined) { + varDefs.push(`$createLabelsIfMissing_${i}: Boolean`); + args.push(`create_labels_if_missing: $createLabelsIfMissing_${i}`); + variables[`createLabelsIfMissing_${i}`] = item.createLabelsIfMissing; + } + + mutations.push(`item_${i}: change_multiple_column_values(${args.join(', ')}) { id name url }`); + }); + + const query = `mutation(${varDefs.join(', ')}) {\n${mutations.join('\n')}\n}`; + return { query, variables }; +} + +export class ChangeItemColumnValuesBatchTool extends BaseMondayApiTool { + name = 'change_item_column_values_batch'; + type = ToolType.WRITE; + annotations = createMondayApiAnnotations({ + title: 'Change Item Column Values (Batch)', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }); + + getDescription(): string { + return ( + 'Update column values for multiple items in a single batch operation. ' + + 'All items are sent in one GraphQL request using aliased mutations — partial failures do not block other items. ' + + 'Returns per-item success/failure results. Max 50 items per batch. ' + + '[REQUIRED PRECONDITION]: Before using this tool, if new columns were added to the board or if you are not familiar with the board structure (column IDs, column types, status labels, etc.), first use get_board_info to understand the board metadata. This is essential for constructing proper column values and knowing which columns are available. ' + + '[REQUIRED PRECONDITION]: For board-relation linking tasks, call link_board_items_workflow before using this tool.' + ); + } + + getInputSchema(): ChangeItemColumnValuesBatchToolInput { + if (this.context?.boardId) { + return changeItemColumnValuesBatchToolSchema; + } + + return changeItemColumnValuesBatchInBoardToolSchema; + } + + protected async executeInternal( + input: ToolInputType, + ): Promise> { + const boardId = + this.context?.boardId ?? (input as ToolInputType).boardId; + + const { query, variables } = buildBatchMutation(boardId.toString(), input.items); + + let data: Record = {}; + let errors: Array<{ message: string; path?: string[] }> = []; + + try { + data = await this.mondayApi.request>(query, variables); + } catch (error: unknown) { + const partialData = (error as any)?.response?.data; + if (partialData) { + data = partialData; + errors = (error as any).response?.errors ?? []; + } else { + return { + content: { + message: `0 of ${input.items.length} items updated successfully, ${input.items.length} failed`, + successful: [], + failed: input.items.map((item) => ({ + itemId: item.itemId, + error: this.extractErrorMessage(error), + })), + }, + }; + } + } + + const successful: Array<{ + itemId: number; + message: string; + item_id: string; + item_name: string; + item_url: string | null; + }> = []; + const failed: Array<{ itemId: number; error: string }> = []; + + input.items.forEach((item, i) => { + const alias = `item_${i}`; + const result = data[alias]; + if (result) { + successful.push({ + itemId: item.itemId, + message: `Item ${result.id} successfully updated`, + item_id: result.id, + item_name: result.name, + item_url: result.url, + }); + } else { + const itemErrors = errors + .filter((e) => e.path?.[0] === alias) + .map((e) => e.message) + .join(', '); + failed.push({ itemId: item.itemId, error: itemErrors || 'Unknown error' }); + } + }); + + return { + content: { + message: `${successful.length} of ${input.items.length} items updated successfully${failed.length > 0 ? `, ${failed.length} failed` : ''}`, + successful, + failed, + }, + }; + } + + private extractErrorMessage(error: unknown): string { + const graphQLErrors = (error as any)?.response?.errors?.map((e: { message: string }) => e.message)?.join(', '); + if (graphQLErrors) { + return graphQLErrors; + } + return error instanceof Error ? error.message : String(error); + } +} diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/index.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/index.ts index 7c121fcb..4f4ffb0f 100644 --- a/packages/agent-toolkit/src/core/tools/platform-api-tools/index.ts +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/index.ts @@ -1,6 +1,7 @@ import { AllMondayApiTool } from './all-monday-api-tool'; import { BaseMondayApiToolConstructor } from './base-monday-api-tool'; import { ChangeItemColumnValuesTool } from './change-item-column-values-tool'; +import { ChangeItemColumnValuesBatchTool } from './change-item-column-values-batch-tool'; import { GetObjectSchemasTool } from './get-object-schemas-tool/get-object-schemas-tool'; import { CreateObjectSchemaTool } from './create-object-schema-tool/create-object-schema-tool'; import { UpdateObjectSchemaTool } from './update-object-schema-tool/update-object-schema-tool'; @@ -82,6 +83,7 @@ export const allGraphqlApiTools: BaseMondayApiToolConstructor[] = [ FullBoardDataTool, ListUsersAndTeamsTool, ChangeItemColumnValuesTool, + ChangeItemColumnValuesBatchTool, MoveItemToGroupTool, CreateBoardTool, CreateFormTool, @@ -153,6 +155,7 @@ export * from './manage-object-schema-board-connection-tool/manage-object-schema export * from './manage-object-schema-columns-tool/manage-object-schema-columns-tool'; export * from './set-object-schema-column-active-state-tool/set-object-schema-column-active-state-tool'; export * from './change-item-column-values-tool'; +export * from './change-item-column-values-batch-tool'; export * from './create-board-tool'; export * from './workforms-tools/create-form-tool'; export * from './workforms-tools/update-form-tool';