diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 33a29b3fe25..98e690d883f 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3346,6 +3346,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { type: 'array', description: 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + items: { type: 'string' }, }, documentId: { type: 'string', description: 'Document ID (required for update_document)' }, documentIds: { @@ -4224,6 +4225,14 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'array', description: 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + items: { + type: 'object', + properties: { + direction: { type: 'string', enum: ['asc', 'desc'] }, + field: { type: 'string' }, + }, + required: ['field', 'direction'], + }, }, rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, @@ -5484,6 +5493,7 @@ export const TableColumns: ToolCatalogEntry = { type: 'array', description: 'Array of column names to delete at once (preferred for multi-column delete_column)', + items: { type: 'string' }, }, multiple: { type: 'boolean', @@ -5751,12 +5761,18 @@ export const TableRows: ToolCatalogEntry = { type: 'array', description: 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + items: { type: 'object' }, }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, updates: { type: 'array', description: "Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + items: { + type: 'object', + properties: { data: { type: 'object' }, rowId: { type: 'string' } }, + required: ['rowId', 'data'], + }, }, values: { type: 'object', @@ -6098,6 +6114,7 @@ export const UserTable: ToolCatalogEntry = { type: 'array', description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', + items: { type: 'string' }, }, cursor: { type: 'string', @@ -6237,6 +6254,14 @@ export const UserTable: ToolCatalogEntry = { type: 'array', description: 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + items: { + type: 'object', + properties: { + direction: { type: 'string', enum: ['asc', 'desc'] }, + field: { type: 'string' }, + }, + required: ['field', 'direction'], + }, }, outputColumnNames: { type: 'object', @@ -6305,6 +6330,7 @@ export const UserTable: ToolCatalogEntry = { type: 'array', description: 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + items: { type: 'object' }, }, runMode: { type: 'string', @@ -6341,6 +6367,11 @@ export const UserTable: ToolCatalogEntry = { type: 'array', description: "Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + items: { + type: 'object', + properties: { data: { type: 'object' }, rowId: { type: 'string' } }, + required: ['rowId', 'data'], + }, }, values: { type: 'object', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3e2e9030146..6af4096bf1c 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3227,6 +3227,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + items: { + type: 'string', + }, }, documentId: { type: 'string', @@ -4105,6 +4108,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + enum: ['asc', 'desc'], + }, + field: { + type: 'string', + }, + }, + required: ['field', 'direction'], + }, }, rowId: { type: 'string', @@ -5359,6 +5375,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Array of column names to delete at once (preferred for multi-column delete_column)', + items: { + type: 'string', + }, }, multiple: { type: 'boolean', @@ -5665,6 +5684,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + items: { + type: 'object', + }, }, tableId: { type: 'string', @@ -5674,6 +5696,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: "Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + items: { + type: 'object', + properties: { + data: { + type: 'object', + }, + rowId: { + type: 'string', + }, + }, + required: ['rowId', 'data'], + }, }, values: { type: 'object', @@ -6032,6 +6066,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', + items: { + type: 'string', + }, }, cursor: { type: 'string', @@ -6186,6 +6223,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + enum: ['asc', 'desc'], + }, + field: { + type: 'string', + }, + }, + required: ['field', 'direction'], + }, }, outputColumnNames: { type: 'object', @@ -6262,6 +6312,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + items: { + type: 'object', + }, }, runMode: { type: 'string', @@ -6300,6 +6353,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'array', description: "Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + items: { + type: 'object', + properties: { + data: { + type: 'object', + }, + rowId: { + type: 'string', + }, + }, + required: ['rowId', 'data'], + }, }, values: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts new file mode 100644 index 00000000000..23b906a4cf7 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * The shapes below are what an agent actually sent when the catalog advertised + * `updates` as a bare array: the provider-path sanitizer filled the missing + * `items` with {type: "string"}, so the model produced `["rowId", "data"]` and + * `[]`, and the executor crashed on the strings. With the item schema synced + * from the catalog, the router refuses them as a classified input error the + * model can correct. + */ +describe('validateGeneratedToolPayload table_rows parameters', () => { + it('rejects string elements in updates as the caller error they are', () => { + expect(() => + validateGeneratedToolPayload('table_rows', 'parameters', { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, + }) + ).toThrow(OrchestrationError) + expect(() => + validateGeneratedToolPayload('table_rows', 'parameters', { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, + }) + ).toThrow(/\/args\/updates\/0 must be object/) + }) + + it('rejects an update patch that omits its data object', () => { + expect(() => + validateGeneratedToolPayload('table_rows', 'parameters', { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1' }] }, + }) + ).toThrow(/\/args\/updates\/0 must have required property 'data'/) + }) + + it('rejects a non-object row in batch_insert_rows', () => { + expect(() => + validateGeneratedToolPayload('table_rows', 'parameters', { + operation: 'batch_insert_rows', + args: { tableId: 'tbl_1', rows: [{ name: 'Ada' }, 'Bob'] }, + }) + ).toThrow(/\/args\/rows\/1 must be object/) + }) + + it('accepts the documented per-row patch shape', () => { + const payload = { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1', data: { status: 'active' } }] }, + } + expect(validateGeneratedToolPayload('table_rows', 'parameters', payload)).toBe(payload) + }) + + it('accepts a sort spec on query_user_table order', () => { + const payload = { + operation: 'query_rows', + args: { tableId: 'tbl_1', order: [{ field: 'age', direction: 'desc' }] }, + } + expect(validateGeneratedToolPayload('query_user_table', 'parameters', payload)).toBe(payload) + expect(() => + validateGeneratedToolPayload('query_user_table', 'parameters', { + operation: 'query_rows', + args: { tableId: 'tbl_1', order: ['age'] }, + }) + ).toThrow(/\/args\/order\/0 must be object/) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 1e96164e1b5..3b510d3d26d 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -14,6 +14,7 @@ const { mockDownloadWorkspaceFile, mockGetTableById, mockBatchInsertRows, + mockBatchUpdateRows, mockInsertRow, mockUpdateRow, mockReplaceTableRows, @@ -44,6 +45,7 @@ const { mockDownloadWorkspaceFile: vi.fn(), mockGetTableById: vi.fn(), mockBatchInsertRows: vi.fn(), + mockBatchUpdateRows: vi.fn(), mockInsertRow: vi.fn(), mockUpdateRow: vi.fn(), mockReplaceTableRows: vi.fn(), @@ -208,7 +210,7 @@ vi.mock('@/lib/table/columns/service', () => ({ vi.mock('@/lib/table/rows/service', () => ({ batchInsertRows: mockBatchInsertRows, - batchUpdateRows: vi.fn(), + batchUpdateRows: mockBatchUpdateRows, deleteRow: vi.fn(), deleteRowsByFilter: mockDeleteRowsByFilter, deleteRowsByIds: vi.fn(), @@ -1843,3 +1845,135 @@ describe('userTableServerTool row writes key model-supplied columns by name', () expect(mockUpdateRow.mock.calls[0][0].data).toEqual({ col_name: 'Grace' }) }) }) + +describe('userTableServerTool.batch_update_rows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + }) + + it('rejects string elements with the expected shape instead of crashing in the executor', async () => { + const result = await userTableServerTool.execute( + { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, + }, + buildToolContext() + ) + + expect(result.success).toBe(false) + expect(result.message).toBe( + 'updates[0] is a string, not a { rowId, data } object. Expected updates: [{ rowId, data: { col: val } }]' + ) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + it('names the element that is missing its data object', async () => { + const result = await userTableServerTool.execute( + { + operation: 'batch_update_rows', + args: { + tableId: 'tbl_1', + updates: [{ rowId: 'row-1', data: { name: 'Ada' } }, { rowId: 'row-2' }], + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(false) + expect(result.message).toBe( + 'updates[1] is missing a data object of column → value pairs. Expected updates: [{ rowId, data: { col: val } }]' + ) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + it('spells out both accepted formats when updates is empty and no values map is given', async () => { + const result = await userTableServerTool.execute( + { operation: 'batch_update_rows', args: { tableId: 'tbl_1', updates: [] } }, + buildToolContext() + ) + + expect(result.success).toBe(false) + expect(result.message).toBe( + 'Provide either a non-empty "updates" array of { rowId, data } objects or "columnName" + "values" map' + ) + expect(mockBatchUpdateRows).not.toHaveBeenCalled() + }) + + it('forwards well-formed per-row patches to the batch service', async () => { + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + + const result = await userTableServerTool.execute( + { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1', data: { name: 'Ada' } }] }, + }, + buildToolContext() + ) + + expect(result).toEqual({ + success: true, + message: 'Updated 1 rows', + data: { affectedCount: 1, affectedRowIds: ['row-1'] }, + }) + expect(mockBatchUpdateRows).toHaveBeenCalledTimes(1) + const call = mockBatchUpdateRows.mock.calls[0][0] as { + updates: Array<{ rowId: string; data: Record }> + } + expect(call.updates).toHaveLength(1) + expect(call.updates[0].rowId).toBe('row-1') + expect(Object.values(call.updates[0].data)).toEqual(['Ada']) + }) + + it('expands the columnName + values map into per-row patches', async () => { + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 2, affectedRowIds: ['row-1', 'row-2'] }) + + const result = await userTableServerTool.execute( + { + operation: 'batch_update_rows', + args: { tableId: 'tbl_1', columnName: 'name', values: { 'row-1': 'Ada', 'row-2': 'Bob' } }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + const call = mockBatchUpdateRows.mock.calls[0][0] as { + updates: Array<{ rowId: string; data: Record }> + } + expect(call.updates.map((update) => update.rowId)).toEqual(['row-1', 'row-2']) + }) +}) + +describe('userTableServerTool.batch_insert_rows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + }) + + it('rejects a row that is not a column → value object', async () => { + const result = await userTableServerTool.execute( + { + operation: 'batch_insert_rows', + args: { tableId: 'tbl_1', rows: [{ name: 'Ada' }, 'Bob'] }, + }, + buildToolContext() + ) + + expect(result.success).toBe(false) + expect(result.message).toBe( + 'rows[1] is a string, not a row data object. Expected rows: [{ col: val }]' + ) + expect(mockBatchInsertRows).not.toHaveBeenCalled() + }) + + it('rejects rows that is not an array', async () => { + const result = await userTableServerTool.execute( + { operation: 'batch_insert_rows', args: { tableId: 'tbl_1', rows: { name: 'Ada' } } }, + buildToolContext() + ) + + expect(result.success).toBe(false) + expect(result.message).toBe('Rows array is required and must not be empty') + expect(mockBatchInsertRows).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index d48ac344d42..74978f50663 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' import { @@ -131,6 +132,32 @@ function resolveAuthorizedWorkflowOutputs( }) } +/** Names a malformed argument's kind the way the model reads it ("a string", "an array", "null"). */ +function describeValueKind(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return 'an array' + const type = typeof value + return type === 'object' ? 'an object' : `a ${type}` +} + +/** + * Why a batch_update_rows `updates` element is not the `{ rowId, data }` patch + * the catalog's item schema declares, or `null` when it is. The router's Ajv + * input validation rejects most of these first; this is the last line for a + * payload that reaches the executor, where a string element used to surface + * as an opaque "Table operation failed" after `Object.entries(undefined)` + * threw deep inside the use case. + */ +function rowUpdateProblem(value: unknown): string | null { + if (!isRecordLike(value)) return `is ${describeValueKind(value)}, not a { rowId, data } object` + if (typeof value.rowId !== 'string' || value.rowId.length === 0) { + return 'is missing a string rowId' + } + if (!isRecordLike(value.data)) return 'is missing a data object of column → value pairs' + return null +} + /** Validates an optional row limit against the policy for the requested surface operation. */ function limitError(limit: unknown, max?: number): string | null { if (limit === undefined) return null @@ -356,9 +383,16 @@ export const userTableServerTool: BaseServerTool if (!args.tableId) { return { success: false, message: 'Table ID is required' } } - if (!args.rows || args.rows.length === 0) { + if (!Array.isArray(args.rows) || args.rows.length === 0) { return { success: false, message: 'Rows array is required and must not be empty' } } + const malformedRow = args.rows.findIndex((row: unknown) => !isRecordLike(row)) + if (malformedRow !== -1) { + return { + success: false, + message: `rows[${malformedRow}] is ${describeValueKind(args.rows[malformedRow])}, not a row data object. Expected rows: [{ col: val }]`, + } + } if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } @@ -699,19 +733,24 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const rawUpdates = (args as Record).updates as - | Array<{ rowId: string; data: Record }> - | undefined - const columnName = (args as Record).columnName as string | undefined - const valuesMap = (args as Record).values as - | Record - | undefined + const rawUpdates: unknown = (args as Record).updates + const columnName: unknown = (args as Record).columnName + const valuesMap: unknown = (args as Record).values let updates: Array<{ rowId: string; data: Record }> - if (rawUpdates && rawUpdates.length > 0) { + if (Array.isArray(rawUpdates) && rawUpdates.length > 0) { + for (let index = 0; index < rawUpdates.length; index += 1) { + const problem = rowUpdateProblem(rawUpdates[index]) + if (problem) { + return { + success: false, + message: `updates[${index}] ${problem}. Expected updates: [{ rowId, data: { col: val } }]`, + } + } + } updates = rawUpdates - } else if (columnName && valuesMap) { + } else if (typeof columnName === 'string' && columnName && isRecordLike(valuesMap)) { updates = Object.entries(valuesMap).map(([rowId, value]) => ({ rowId, data: { [columnName]: value }, @@ -719,7 +758,8 @@ export const userTableServerTool: BaseServerTool } else { return { success: false, - message: 'Provide either "updates" array or "columnName" + "values" map', + message: + 'Provide either a non-empty "updates" array of { rowId, data } objects or "columnName" + "values" map', } } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index ef8257efdc7..f2848ebe1a1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -712,23 +712,38 @@ describe('terminal-title projection is idempotent', () => { describe('resource-naming titles', () => { it('names the table a row/column operation targets', () => { - expect(getToolDisplayTitle('table_rows', { operation: 'insert', tableName: 'Runtimes' })).toBe( - 'Adding rows to Runtimes' - ) + expect( + getToolDisplayTitle('table_rows', { operation: 'insert_row', tableName: 'Runtimes' }) + ).toBe('Adding rows to Runtimes') expect( getToolDisplayTitle('table_columns', { - operation: 'add', + operation: 'add_column', columnName: 'status', tableName: 'Runtimes', }) ).toBe('Adding column status in Runtimes') - expect(getToolDisplayTitle('table_views', { operation: 'list', tableName: 'Runtimes' })).toBe( - 'Reading views of Runtimes' - ) + expect( + getToolDisplayTitle('table_views', { operation: 'list_views', tableName: 'Runtimes' }) + ).toBe('Reading views of Runtimes') + }) + + it('matches the verb inside compound operation ids', () => { + expect( + getToolDisplayTitle('table_rows', { operation: 'batch_update_rows', tableName: 'Runtimes' }) + ).toBe('Updating rows in Runtimes') + expect( + getToolDisplayTitle('table_rows', { + operation: 'delete_rows_by_filter', + tableName: 'Runtimes', + }) + ).toBe('Deleting rows in Runtimes') + expect( + getToolDisplayTitle('table_views', { operation: 'set_default_view', tableName: 'Runtimes' }) + ).toBe('Editing views of Runtimes') }) it('falls back cleanly when the table is unnamed', () => { - expect(getToolDisplayTitle('table_rows', { operation: 'update' })).toBe('Updating rows') + expect(getToolDisplayTitle('table_rows', { operation: 'update_row' })).toBe('Updating rows') }) it('names the block behind a block-schema read', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 1633f7bbe68..32a5a029daf 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -76,6 +76,16 @@ function stringOrNumberArg(args: ToolArgs, key: string): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' } +/** + * The verb tokens of a table operation id. Ids are verb_noun compounds — + * `insert_row`, `batch_update_rows`, `delete_rows_by_filter`, `list_views` — + * so a title matches the verb as a token; whole-id equality never fired in + * production and every row write rendered as the generic "Editing rows". + */ +function operationVerbs(op: string): Set { + return new Set(op.split('_')) +} + /** * Titles for the split table tools: each names its own action, refined by the * operation and the named target when the args carry one — a card full of @@ -84,6 +94,8 @@ function stringOrNumberArg(args: ToolArgs, key: string): string { */ function splitTableTitle(name: string, args: ToolArgs): string { const op = stringArg(args, 'operation') + const verbs = operationVerbs(op) + const is = (...candidates: string[]) => candidates.some((verb) => verbs.has(verb)) const target = firstStringArg(args, 'columnName', 'viewName', 'name', 'title') const suffix = target ? ` ${target}` : '' // "in Runtimes" / "of Runtimes" — enrichment resolves the nested tableId. @@ -92,36 +104,34 @@ function splitTableTitle(name: string, args: ToolArgs): string { const ofTable = table ? ` of ${table}` : '' switch (name) { case 'table_manage': - if (op === 'create') return `Creating table${suffix || (table ? ` ${table}` : '')}` - if (op === 'delete') return `Deleting table${table ? ` ${table}` : suffix}` - if (op === 'read' || op === 'get' || op === 'list') - return `Reading${table ? ` ${table}` : ' table'}` + if (is('create')) return `Creating table${suffix || (table ? ` ${table}` : '')}` + if (is('delete')) return `Deleting table${table ? ` ${table}` : suffix}` + if (is('read', 'get', 'list')) return `Reading${table ? ` ${table}` : ' table'}` return `Updating${table ? ` ${table}` : ' table'}` case 'table_rows': - if (op === 'insert' || op === 'add' || op === 'create') - return `Adding rows${inTable ? ` to ${table}` : ''}` - if (op === 'update') return `Updating rows${inTable}` - if (op === 'delete') return `Deleting rows${inTable}` - if (op === 'read' || op === 'list' || op === 'query') return `Reading rows${ofTable}` + if (is('insert', 'add', 'create')) return `Adding rows${inTable ? ` to ${table}` : ''}` + if (is('update')) return `Updating rows${inTable}` + if (is('delete')) return `Deleting rows${inTable}` + if (is('read', 'get', 'list', 'query')) return `Reading rows${ofTable}` return `Editing rows${ofTable}` case 'table_columns': - if (op === 'add' || op === 'create') return `Adding column${suffix}${inTable}` - if (op === 'update') return `Updating column${suffix}${inTable}` - if (op === 'delete') return `Deleting column${suffix}${inTable}` - if (op === 'read' || op === 'list') return `Reading columns${ofTable}` + if (is('add', 'create')) return `Adding column${suffix}${inTable}` + if (is('update')) return `Updating column${suffix}${inTable}` + if (is('delete')) return `Deleting column${suffix}${inTable}` + if (is('read', 'get', 'list')) return `Reading columns${ofTable}` return `Editing columns${ofTable}` case 'table_automations': - if (op === 'read' || op === 'list') return `Reading automations${ofTable}` - if (op === 'delete') return `Removing automation${inTable}` + if (is('read', 'get', 'list')) return `Reading automations${ofTable}` + if (is('delete')) return `Removing automation${inTable}` return `Wiring automation${inTable}` case 'table_enrichments': - if (op === 'read' || op === 'list') return `Reading enrichments${ofTable}` - if (op === 'delete') return `Removing enrichment${inTable}` + if (is('read', 'get', 'list')) return `Reading enrichments${ofTable}` + if (is('delete')) return `Removing enrichment${inTable}` return `Configuring enrichment${suffix}${inTable}` case 'table_views': - if (op === 'create') return `Creating view${suffix}${inTable}` - if (op === 'delete') return `Deleting view${suffix}${inTable}` - if (op === 'read' || op === 'list') return `Reading views${ofTable}` + if (is('create')) return `Creating view${suffix}${inTable}` + if (is('delete')) return `Deleting view${suffix}${inTable}` + if (is('read', 'get', 'list')) return `Reading views${ofTable}` return `Editing views${ofTable}` default: return `Updating${table ? ` ${table}` : ' table'}`