diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.test.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.test.ts new file mode 100644 index 00000000..4d4dda41 --- /dev/null +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.test.ts @@ -0,0 +1,121 @@ +import { + normalizeDropdownColumnSettings, + parseColumnSettings, + sanitizeColumnSettings, + truncateStatusLabelDescriptions, +} from './update-column-tool.helpers'; + +describe('update-column-tool.helpers', () => { + describe('parseColumnSettings', () => { + it('parses JSON strings', () => { + expect(parseColumnSettings('{"labels":[]}')).toEqual({ labels: [] }); + }); + + it('rejects invalid JSON', () => { + expect(() => parseColumnSettings('{')).toThrow('Invalid columnSettings JSON'); + }); + }); + + describe('truncateStatusLabelDescriptions', () => { + it('truncates descriptions longer than 80 characters', () => { + const longDescription = 'a'.repeat(120); + const { settings, truncatedDescriptions } = truncateStatusLabelDescriptions({ + labels: [{ label: 'Verified', description: longDescription, color: 'grass_green', index: 0 }], + }); + + expect((settings.labels as { description: string }[])[0].description).toHaveLength(80); + expect(truncatedDescriptions).toEqual(['Verified']); + }); + }); + + describe('normalizeDropdownColumnSettings', () => { + it('converts new labels in a full list to MODIFY_LABELS actions', () => { + const result = normalizeDropdownColumnSettings({ + labels: [ + { id: 1, label: 'Existing', is_deactivated: false }, + { label: 'TARA', is_deactivated: false }, + ], + }); + + expect(result.labels).toBeUndefined(); + expect(result.action).toEqual({ + type: 'MODIFY_LABELS', + payload: [{ type: 'CREATE', label: { name: 'TARA' } }], + }); + }); + + it('normalizes label field to name', () => { + const result = normalizeDropdownColumnSettings({ + labels: [{ id: 1, label: 'Existing', is_deactivated: false }], + }); + + expect(result.labels).toEqual([{ id: 1, name: 'Existing', is_deactivated: false }]); + }); + }); + + describe('sanitizeColumnSettings', () => { + it('returns warnings for status and dropdown normalization', () => { + const status = sanitizeColumnSettings('status', { + labels: [{ label: 'Done', description: 'x'.repeat(100), color: 1, index: 0 }], + }); + expect(status.warnings[0]).toContain('Truncated status label descriptions'); + + const dropdown = sanitizeColumnSettings('dropdown', { + labels: [{ id: 1, label: 'A', is_deactivated: false }, { label: 'B', is_deactivated: false }], + }); + expect(dropdown.warnings[0]).toContain('MODIFY_LABELS actions'); + }); + + it('keeps status label ids in array settings and strips unsupported top-level keys', () => { + const status = sanitizeColumnSettings('status', { + labels: [ + { id: 15, label: 'A', description: 'x'.repeat(120), color: 'dark_orange', index: 1 }, + ], + done_colors: [15], + } as any); + + const labels = status.settings?.labels as any[]; + expect(Object.keys(status.settings ?? {})).toEqual(['labels']); + expect(labels[0].id).toBe(15); + expect(labels[0].description).toHaveLength(80); + expect(status.warnings[0]).toContain('Truncated status label descriptions'); + }); + + it('coerces status UI object shape into API labels array', () => { + const status = sanitizeColumnSettings('status', { + done_colors: [1], + sumType: 'allStatuses', + labels: { + 0: 'התקבלו חומרים', + 1: 'הופץ', + }, + labels_positions_v2: { + 0: 2, + 1: 5, + }, + labels_colors: { + 0: { var_name: 'orange' }, + 1: { var_name: 'green-shadow' }, + }, + } as any); + + const labels = status.settings?.labels as any[]; + expect(Object.keys(status.settings ?? {})).toEqual(['labels']); + expect(labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'התקבלו חומרים', + index: 2, + color: 'working_orange', + }), + expect.objectContaining({ + label: 'הופץ', + index: 5, + color: 'done_green', + is_done: true, + }), + ]), + ); + }); + }); +}); diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.ts new file mode 100644 index 00000000..460612f2 --- /dev/null +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.helpers.ts @@ -0,0 +1,281 @@ +import { GraphQLErrorResponse } from '../../../utils/graphql-error.types'; + +export const STATUS_LABEL_DESCRIPTION_MAX_LENGTH = 80; + +export type ColumnSettings = Record; + +type DropdownLabel = { + id?: number; + name?: string; + label?: string; + is_deactivated?: boolean; +}; + +type StatusLabel = { + id?: number; + label?: string; + color?: string | number; + index?: number; + description?: string; + is_done?: boolean; + is_deactivated?: boolean; +}; + +export function parseColumnSettings(columnSettings: unknown): ColumnSettings | undefined { + if (columnSettings === undefined || columnSettings === null) { + return undefined; + } + + if (typeof columnSettings === 'string') { + try { + const parsed = JSON.parse(columnSettings); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('columnSettings must be a JSON object'); + } + return parsed as ColumnSettings; + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid JSON'; + throw new Error(`Invalid columnSettings JSON: ${message}`); + } + } + + if (typeof columnSettings === 'object' && !Array.isArray(columnSettings)) { + return columnSettings as ColumnSettings; + } + + throw new Error('columnSettings must be a JSON object'); +} + +export function truncateStatusLabelDescriptions(settings: ColumnSettings): { + settings: ColumnSettings; + truncatedDescriptions: string[]; +} { + const labels = settings.labels; + if (!Array.isArray(labels)) { + return { settings, truncatedDescriptions: [] }; + } + + const truncatedDescriptions: string[] = []; + const normalizedLabels = (labels as StatusLabel[]).map((label) => { + if (typeof label.description !== 'string') { + return label; + } + + if (label.description.length <= STATUS_LABEL_DESCRIPTION_MAX_LENGTH) { + return label; + } + + truncatedDescriptions.push(label.label ?? `index:${label.index ?? 'unknown'}`); + return { + ...label, + description: label.description.slice(0, STATUS_LABEL_DESCRIPTION_MAX_LENGTH), + }; + }); + + return { + settings: { ...settings, labels: normalizedLabels }, + truncatedDescriptions, + }; +} + +const STATUS_COLOR_VAR_NAME_MAP: Record = { + // Common monday.com status label UI var_name -> StatusColumnColors enum values + orange: 'working_orange', + 'green-shadow': 'done_green', + 'red-shadow': 'stuck_red', + 'blue-links': 'dark_blue', + purple: 'purple', + grey: 'explosive', + 'grass-green': 'grass_green', + 'bright-blue': 'bright_blue', + musterred: 'saladish', + yellow: 'egg_yolk', + 'soft-black': 'blackish', + pecan: 'pecan', + 'dark-pink': 'sofia_pink', + 'light-pink': 'lipstick', + sunset: 'sunset', + 'orange-hot': 'dark_orange', + 'dark-red': 'dark_red', +}; + +function mapStatusColorVarNameToEnum(varName: unknown): string | undefined { + if (typeof varName !== 'string' || varName.length === 0) { + return undefined; + } + + return STATUS_COLOR_VAR_NAME_MAP[varName] ?? varName; +} + +function normalizeStatusColumnSettings(settings: ColumnSettings): ColumnSettings { + // API expects UpdateStatusColumnSettingsInput => { labels: [ ... ] }. + // Agents sometimes send extra top-level keys or a UI object map for labels. + const labels = settings.labels; + + // Already in API array shape: keep label ids (required for existing labels) and + // strip unsupported top-level keys such as done_colors or labels_colors. + if (Array.isArray(labels)) { + return { labels }; + } + + // UI object shape: { labels: {: }, labels_positions_v2, labels_colors, done_colors, ... } + if (labels && typeof labels === 'object') { + const labelsObj = labels as Record; + const positions = settings.labels_positions_v2 as Record | undefined; + const colors = settings.labels_colors as Record | undefined; + const doneColors = settings.done_colors as unknown; + + const doneSet = new Set(); + if (Array.isArray(doneColors)) { + for (const v of doneColors) { + if (typeof v === 'number' || typeof v === 'string') { + doneSet.add(String(v)); + } + } + } + + const keys = Object.keys(labelsObj); + const normalized = keys + .map((key) => { + const indexValue = positions?.[key]; + const index = + typeof indexValue === 'number' + ? indexValue + : typeof indexValue === 'string' + ? Number(indexValue) + : undefined; + + const varName = colors?.[key]?.var_name ?? colors?.[key]?.varName; + const color = mapStatusColorVarNameToEnum(varName); + const labelText = labelsObj[key] as unknown; + + if (typeof labelText !== 'string') { + return undefined; + } + + const parsedId = Number(key); + + return { + ...(Number.isFinite(parsedId) ? { id: parsedId } : {}), + label: labelText, + color, + index, + ...(doneSet.has(key) ? { is_done: true } : {}), + }; + }) + .filter( + (x): x is { id?: number; label: string; color: string | undefined; index: number | undefined; is_done?: boolean } => + x !== undefined, + ) + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); + + return { labels: normalized }; + } + + return settings; +} + +function normalizeDropdownLabel(label: DropdownLabel): DropdownLabel { + const name = label.name ?? label.label; + const normalized: DropdownLabel = { + ...(label.id !== undefined ? { id: label.id } : {}), + ...(name !== undefined ? { name } : {}), + ...(label.is_deactivated !== undefined ? { is_deactivated: label.is_deactivated } : {}), + }; + + return normalized; +} + +function buildDropdownCreateActions(newLabels: DropdownLabel[]) { + return newLabels.map((label) => ({ + type: 'CREATE', + label: { + name: label.name ?? label.label ?? '', + }, + })); +} + +export function normalizeDropdownColumnSettings(settings: ColumnSettings): ColumnSettings { + if (!Array.isArray(settings.labels)) { + return settings; + } + + const labels = (settings.labels as DropdownLabel[]).map(normalizeDropdownLabel); + const newLabels = labels.filter((label) => label.id === undefined && (label.name ?? label.label)); + + if (newLabels.length === 0) { + return { ...settings, labels }; + } + + const existingLabels = labels.filter((label) => label.id !== undefined); + + if (existingLabels.length > 0) { + const { labels: _labels, ...rest } = settings; + return { + ...rest, + action: { + type: 'MODIFY_LABELS', + payload: buildDropdownCreateActions(newLabels), + }, + }; + } + + return { ...settings, labels }; +} + +export function sanitizeColumnSettings( + columnType: string, + settings: ColumnSettings | undefined, +): { settings: ColumnSettings | undefined; warnings: string[] } { + if (!settings) { + return { settings, warnings: [] }; + } + + const warnings: string[] = []; + let normalizedSettings = settings; + + if (columnType === 'status') { + const coerced = normalizeStatusColumnSettings(settings); + const { settings: statusSettings, truncatedDescriptions } = truncateStatusLabelDescriptions(coerced); + normalizedSettings = statusSettings; + if (truncatedDescriptions.length > 0) { + warnings.push( + `Truncated status label descriptions to ${STATUS_LABEL_DESCRIPTION_MAX_LENGTH} characters for: ${truncatedDescriptions.join(', ')}`, + ); + } + } + + if (columnType === 'dropdown') { + normalizedSettings = normalizeDropdownColumnSettings(normalizedSettings); + if (normalizedSettings.action) { + warnings.push('Converted dropdown label additions to MODIFY_LABELS actions to avoid bulk label update failures.'); + } + } + + return { settings: normalizedSettings, warnings }; +} + +export function getGraphQLErrorCode(error: unknown): string | undefined { + const response = (error as GraphQLErrorResponse)?.response; + return response?.errors?.[0]?.extensions?.code as string | undefined; +} + +export function isRevisionMismatchError(error: unknown): boolean { + return getGraphQLErrorCode(error) === 'REVISION_MISMATCH'; +} + +export async function fetchColumnRevision( + request: (query: any, variables: { boardId: string }) => Promise<{ boards?: { columns?: { id?: string; revision?: string | null }[] | null }[] | null }>, + getBoardSchemaQuery: any, + boardId: string, + columnId: string, +): Promise { + const result = await request(getBoardSchemaQuery, { boardId }); + const column = result.boards?.[0]?.columns?.find((entry) => entry?.id === columnId); + + if (!column?.revision) { + throw new Error(`Could not fetch revision for column ${columnId} on board ${boardId}. Call get_board_schema first.`); + } + + return column.revision; +} diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.test.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.test.ts new file mode 100644 index 00000000..c4d8a6d6 --- /dev/null +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.test.ts @@ -0,0 +1,128 @@ +import { UpdateColumnTool } from './update-column-tool'; + +function createMockApiClient() { + const mockRequest = jest.fn(); + return { + mockApiClient: { request: mockRequest } as any, + mockRequest, + setResponses: (responses: any[]) => { + responses.forEach((response) => { + mockRequest.mockResolvedValueOnce(response); + }); + }, + getMockRequest: () => mockRequest, + }; +} + +describe('UpdateColumnTool', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = createMockApiClient(); + }); + + it('retries once after REVISION_MISMATCH', async () => { + const revisionMismatchError = Object.assign(new Error('Board revision mismatch'), { + response: { + errors: [{ message: 'Board revision mismatch', extensions: { code: 'REVISION_MISMATCH', status_code: 409 } }], + data: { update_column: null }, + status: 200, + headers: {}, + }, + }); + + mocks.getMockRequest().mockRejectedValueOnce(revisionMismatchError); + mocks.setResponses([ + { + boards: [{ columns: [{ id: 'relation_col', revision: 'fresh-rev' }] }], + }, + { + update_column: { id: 'relation_col', title: 'Relation', revision: 'new-rev' }, + }, + ]); + + const tool = new UpdateColumnTool(mocks.mockApiClient); + const result = await tool.execute({ + boardId: 456, + columnId: 'relation_col', + columnType: 'board_relation' as any, + revision: 'stale-rev', + columnSettings: JSON.stringify({ boardIds: [789], allowMultipleItems: true }), + }); + + expect((result.content as any).revision).toBe('new-rev'); + expect((result.content as any).warnings).toEqual( + expect.arrayContaining(['Retried update_column after REVISION_MISMATCH using a fresh revision.']), + ); + expect(mocks.getMockRequest()).toHaveBeenCalledTimes(3); + }); + + it('sanitizes status label descriptions before calling the API', async () => { + mocks.setResponses([ + { + update_column: { id: 'status_col', title: 'Status', revision: 'rev-2' }, + }, + ]); + + const tool = new UpdateColumnTool(mocks.mockApiClient); + const longDescription = 'x'.repeat(100); + await tool.execute({ + boardId: 123, + columnId: 'status_col', + columnType: 'status' as any, + revision: 'rev-1', + columnSettings: JSON.stringify({ + labels: [{ label: 'Verified', description: longDescription, color: 'grass_green', index: 0 }], + }), + }); + + const updateCall = mocks.getMockRequest().mock.calls[0]; + expect(updateCall[1].columnSettings.labels[0].description).toHaveLength(80); + }); + + it('converts dropdown label additions to MODIFY_LABELS actions', async () => { + mocks.setResponses([ + { + update_column: { id: 'dropdown_col', title: 'Dropdown', revision: 'rev-2' }, + }, + ]); + + const tool = new UpdateColumnTool(mocks.mockApiClient); + await tool.execute({ + boardId: 123, + columnId: 'dropdown_col', + columnType: 'dropdown' as any, + revision: 'rev-1', + columnSettings: JSON.stringify({ + labels: [{ id: 1, label: 'Existing', is_deactivated: false }, { label: 'TARA', is_deactivated: false }], + }), + }); + + const updateCall = mocks.getMockRequest().mock.calls[0]; + expect(updateCall[1].columnSettings.action).toEqual({ + type: 'MODIFY_LABELS', + payload: [{ type: 'CREATE', label: { name: 'TARA' } }], + }); + expect(updateCall[1].columnSettings.labels).toBeUndefined(); + }); + + it('rejects item value updates with a helpful error', async () => { + const tool = new UpdateColumnTool(mocks.mockApiClient); + + await expect( + tool.execute({ + boardId: 123, + columnId: 'status_col', + columnType: 'status' as any, + revision: 'rev-1', + itemId: 456, + value: { label: 'Done' }, + }), + ).rejects.toMatchObject({ + code: 'INVALID_TOOL_ARGS', + message: expect.stringContaining('change_item_column_values'), + }); + + expect(mocks.getMockRequest()).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.ts b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.ts index 4159262e..d841113b 100644 --- a/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.ts +++ b/packages/agent-toolkit/src/core/tools/platform-api-tools/update-column-tool.ts @@ -1,10 +1,33 @@ import { z } from 'zod'; -import { UpdateColumnMutation, UpdateColumnMutationVariables } from 'src/monday-graphql/generated/graphql/graphql'; -import { updateColumn } from '../../../monday-graphql/queries.graphql'; +import { updateColumn, getBoardSchema } from '../../../monday-graphql/queries.graphql'; import { ToolInputType, ToolOutputType, ToolType } from '../../tool'; import { BaseMondayApiTool, createMondayApiAnnotations } from './base-monday-api-tool'; -import { ColumnTypeInfoFetchMode } from './get-column-type-info/get-column-type-info-fetch-mode'; import { NonDeprecatedColumnType } from 'src/utils/types'; +import { INVALID_TOOL_ARGS_CODE, rethrowWithContext, ToolValidationError } from '../../../utils/error.utils'; +import { + fetchColumnRevision, + isRevisionMismatchError, + parseColumnSettings, + sanitizeColumnSettings, +} from './update-column-tool.helpers'; + +type UpdateColumnMutationVariables = { + boardId: string; + columnId: string; + columnType: string; + revision: string; + columnTitle?: string; + columnDescription?: string; + columnSettings?: Record; +}; + +type UpdateColumnMutation = { + update_column?: { + id?: string | null; + title?: string | null; + revision?: string | null; + } | null; +}; export const updateColumnToolSchema = { columnId: z.string().describe('The id of the column to update'), @@ -14,7 +37,7 @@ export const updateColumnToolSchema = { revision: z .string() .describe( - 'The current revision of the column, obtained from get_board_schema. Used for optimistic concurrency control: if the column changed since you read it, the request will fail and you should re-fetch the latest revision before retrying.', + 'Required. Current column revision from get_board_schema. Used for optimistic concurrency control — if the column changed since you read it, the request fails with REVISION_MISMATCH and you must re-fetch the latest revision before retrying.', ), columnTitle: z.string().optional().describe('The new title of the column. If omitted, the title is unchanged.'), columnDescription: z @@ -25,8 +48,16 @@ export const updateColumnToolSchema = { .string() .optional() .describe( - `Type-specific configuration as a JSON string. Use get_column_type_info with fetchMode "${ColumnTypeInfoFetchMode.Schema}" for the JSON schema for the given column type. If omitted, settings are unchanged.`, + 'Type-specific configuration as a JSON string. Use get_column_type_info for the JSON schema. If omitted, settings are unchanged. For status columns: fetch label ids from get_board_schema (not from item column values); existing labels must include id, new labels must omit id; descriptions are limited to 80 characters. For dropdown columns: do not resend the full labels list when adding one label — the tool converts new labels to MODIFY_LABELS actions.', ), + itemId: z + .union([z.number(), z.string()]) + .optional() + .describe('Do not use — update_column changes column definitions, not item values. Use change_item_column_values instead.'), + value: z + .unknown() + .optional() + .describe('Do not use — update_column changes column definitions, not item values. Use change_item_column_values instead.'), }; export const updateColumnInBoardToolSchema = { @@ -47,7 +78,15 @@ export class UpdateColumnTool extends BaseMondayApiTool { }); getDescription(): string { - return 'Update properties of an existing monday.com column (title, description, settings). Uses optimistic concurrency control via the revision field — fetch the current revision via get_board_schema first, then call this tool. If the update fails because the revision is stale, re-fetch and try again.'; + return ( + 'Update properties of an existing monday.com column (title, description, settings). ' + + 'Do NOT use this tool to change item column values — use change_item_column_values for that. ' + + 'Always call get_board_schema first to obtain the current revision and label ids. ' + + 'Revision is required for optimistic concurrency control. If the update fails with REVISION_MISMATCH, re-fetch the revision and retry once. ' + + 'For status labels: use label ids from get_board_schema for existing labels; new labels must not include id. Do not reuse ids from item column values. ' + + 'Keep status label descriptions under 80 characters. ' + + 'For dropdown labels: add labels via MODIFY_LABELS actions — do not resend the entire labels list unless you intend to replace all labels.' + ); } getInputSchema(): UpdateColumnToolInput { @@ -59,28 +98,76 @@ export class UpdateColumnTool extends BaseMondayApiTool { } protected async executeInternal(input: ToolInputType): Promise> { + const itemValueInput = input as ToolInputType & { + itemId?: number | string; + value?: unknown; + }; + if (itemValueInput.itemId !== undefined || itemValueInput.value !== undefined) { + throw new ToolValidationError( + 'update_column modifies column definitions (title, description, settings), not item values. Use change_item_column_values with itemId and columnValues instead.', + INVALID_TOOL_ARGS_CODE, + ); + } + const boardId = this.context?.boardId ?? (input as ToolInputType).boardId; + const boardIdString = boardId?.toString() ?? ''; - const variables: UpdateColumnMutationVariables = { - boardId: boardId?.toString() ?? '', - columnId: input.columnId, - columnType: input.columnType, - revision: input.revision, - columnTitle: input.columnTitle, - columnDescription: input.columnDescription, - columnSettings: - typeof input.columnSettings === 'string' ? JSON.parse(input.columnSettings) : input.columnSettings, - }; + let parsedSettings: Record | undefined; + try { + parsedSettings = parseColumnSettings(input.columnSettings); + } catch (error) { + rethrowWithContext(error, 'update column'); + } + + const { settings: sanitizedSettings, warnings } = sanitizeColumnSettings(input.columnType, parsedSettings); - const res = await this.mondayApi.request(updateColumn, variables); + const executeUpdate = async (currentRevision: string) => { + const variables: UpdateColumnMutationVariables = { + boardId: boardIdString, + columnId: input.columnId, + columnType: input.columnType, + revision: currentRevision, + columnTitle: input.columnTitle, + columnDescription: input.columnDescription, + columnSettings: sanitizedSettings, + }; - return { - content: { - message: 'Column successfully updated. Use the new revision below for any subsequent update to this column.', - column_id: res.update_column?.id, - column_title: res.update_column?.title, - revision: res.update_column?.revision, - }, + return this.mondayApi.request(updateColumn, variables); }; + + try { + let res: UpdateColumnMutation; + try { + res = await executeUpdate(input.revision); + } catch (error) { + if (!isRevisionMismatchError(error)) { + throw error; + } + + const freshRevision = await fetchColumnRevision( + (query, variables) => this.mondayApi.request(query, variables), + getBoardSchema, + boardIdString, + input.columnId, + ); + res = await executeUpdate(freshRevision); + warnings.push('Retried update_column after REVISION_MISMATCH using a fresh revision.'); + } + + return { + content: { + message: [ + 'Column successfully updated. Use the new revision below for any subsequent update to this column.', + ...warnings, + ].join(' '), + column_id: res.update_column?.id, + column_title: res.update_column?.title, + revision: res.update_column?.revision, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }; + } catch (error) { + rethrowWithContext(error, 'update column'); + } } }