diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 80798ad2b0..6270761fc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1330,3 +1330,33 @@ describe('parseSpecialTags sim_key placeholder', () => { } }) }) + +describe('recoverTrailingBareOptions', () => { + const bareOptions = + '{"1": {"title": "Fix the tracker", "description": "debug"}, "2": {"title": "Inspect the miss", "description": "look"}}' + + it('renders a trailing bare-JSON options payload as an options card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, false) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(last.data['1']?.title).toBe('Fix the tracker') + } + expect(segments[0]).toEqual({ type: 'text', content: 'Here they are.' }) + }) + + it('never recovers mid-stream — a partial JSON tail must not flicker into a card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, true) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('leaves ordinary JSON prose alone', () => { + const { segments } = parseSpecialTags('The config is {"retries": 3, "mode": "fast"}', false) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('does not double-render when a real options tag already parsed', () => { + const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) + expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0562ce7c61..21596a05ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1442,8 +1442,51 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS segments.push({ type: 'text', content }) } + if (!isStreaming) { + recoverTrailingBareOptions(segments) + } + return { segments, hasPendingTag } } +/** + * Recovers a trailing bare-JSON options payload the model emitted WITHOUT the + * `` wrapper (observed when an automation prompt asks the model to + * "(re)send suggested actions" and it answers with the JSON as content). The + * shape check is strict — a non-empty object whose every value is + * { title, description } with numeric-string keys — so ordinary JSON in prose + * cannot false-positive. Only a message's FINAL text segment is considered, + * mirroring the tag contract (options go last), and only when no options tag + * already parsed. Never applied mid-stream: a partial JSON tail must not + * flicker between prose and a card. + */ +function recoverTrailingBareOptions(segments: ContentSegment[]): void { + const last = segments[segments.length - 1] + if (!last || last.type !== 'text') return + if (segments.some((segment) => segment.type === 'options')) return + const text = last.content + if (!text.trimEnd().endsWith('}')) return + // The payload nests objects, so the START brace is the first one from which + // the remainder parses — probe brace positions left to right (bounded). + let start = -1 + let parsed: unknown + let probe = text.indexOf('{') + for (let attempts = 0; probe !== -1 && attempts < 20; attempts++) { + try { + parsed = JSON.parse(text.slice(probe).trim()) + start = probe + break + } catch { + probe = text.indexOf('{', probe + 1) + } + } + if (start === -1) return + if (!isOptionsTagData(parsed) || Object.keys(parsed as object).length === 0) return + if (!Object.keys(parsed as object).every((key) => /^\d+$/.test(key))) return + const prefix = text.slice(0, start).replace(/\s+$/, '') + segments.pop() + if (prefix) segments.push({ type: 'text', content: prefix }) + segments.push({ type: 'options', data: parsed }) +} interface SpecialTagsProps { segment: Exclude diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 91fe4c4c2e..3d522d7035 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -113,6 +113,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -229,6 +234,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -2067,7 +2077,7 @@ export const EnrichmentRun: ToolCatalogEntry = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4900,6 +4910,505 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const TableAutomations: ToolCatalogEntry = { + id: 'table_automations', + name: 'table_automations', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: 'Input column names that must be filled before the group runs a row.', + items: { type: 'string' }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { type: 'string' }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'New source block ID for this column.' }, + columnName: { + type: 'string', + description: 'The existing output column to remap (must be bound to this group).', + }, + path: { type: 'string', description: 'New dotted output path on the new block.' }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Source block ID inside the workflow.' }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { type: 'string', description: 'Dotted output path on the block.' }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { type: 'string', description: 'Row ID for cancel_table_runs with scope "row".' }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { type: 'string' }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableColumns: ToolCatalogEntry = { + id: 'table_columns', + name: 'table_columns', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { type: 'string', description: 'New column name (required for rename_column)' }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { type: 'string' }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableEnrichments: ToolCatalogEntry = { + id: 'table_enrichments', + name: 'table_enrichments', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { type: 'string' }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for add_enrichment)' }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableManage: ToolCatalogEntry = { + id: 'table_manage', + name: 'table_manage', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { type: 'string', description: 'Table description (optional for create)' }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { type: 'string', description: 'Table name (required for create)' }, + newName: { type: 'string', description: 'New table name (required for rename)' }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableRows: ToolCatalogEntry = { + id: 'table_rows', + name: 'table_rows', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { type: 'string', description: 'Row ID (required for update_row, delete_row)' }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { type: 'string' }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + 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)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -5788,6 +6297,101 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TableAutomationsOperation = { + listWorkflowOutputs: 'list_workflow_outputs', + addWorkflowGroup: 'add_workflow_group', + updateWorkflowGroup: 'update_workflow_group', + deleteWorkflowGroup: 'delete_workflow_group', + addWorkflowGroupOutput: 'add_workflow_group_output', + deleteWorkflowGroupOutput: 'delete_workflow_group_output', + runColumn: 'run_column', + cancelTableRuns: 'cancel_table_runs', +} as const + +export type TableAutomationsOperation = + (typeof TableAutomationsOperation)[keyof typeof TableAutomationsOperation] + +export const TableAutomationsOperationValues = [ + TableAutomationsOperation.listWorkflowOutputs, + TableAutomationsOperation.addWorkflowGroup, + TableAutomationsOperation.updateWorkflowGroup, + TableAutomationsOperation.deleteWorkflowGroup, + TableAutomationsOperation.addWorkflowGroupOutput, + TableAutomationsOperation.deleteWorkflowGroupOutput, + TableAutomationsOperation.runColumn, + TableAutomationsOperation.cancelTableRuns, +] as const + +export const TableColumnsOperation = { + addColumn: 'add_column', + renameColumn: 'rename_column', + deleteColumn: 'delete_column', + updateColumn: 'update_column', +} as const + +export type TableColumnsOperation = + (typeof TableColumnsOperation)[keyof typeof TableColumnsOperation] + +export const TableColumnsOperationValues = [ + TableColumnsOperation.addColumn, + TableColumnsOperation.renameColumn, + TableColumnsOperation.deleteColumn, + TableColumnsOperation.updateColumn, +] as const + +export const TableEnrichmentsOperation = { + listEnrichments: 'list_enrichments', + addEnrichment: 'add_enrichment', +} as const + +export type TableEnrichmentsOperation = + (typeof TableEnrichmentsOperation)[keyof typeof TableEnrichmentsOperation] + +export const TableEnrichmentsOperationValues = [ + TableEnrichmentsOperation.listEnrichments, + TableEnrichmentsOperation.addEnrichment, +] as const + +export const TableManageOperation = { + create: 'create', + createFromFile: 'create_from_file', + importFile: 'import_file', + rename: 'rename', +} as const + +export type TableManageOperation = (typeof TableManageOperation)[keyof typeof TableManageOperation] + +export const TableManageOperationValues = [ + TableManageOperation.create, + TableManageOperation.createFromFile, + TableManageOperation.importFile, + TableManageOperation.rename, +] as const + +export const TableRowsOperation = { + insertRow: 'insert_row', + batchInsertRows: 'batch_insert_rows', + updateRow: 'update_row', + batchUpdateRows: 'batch_update_rows', + deleteRow: 'delete_row', + batchDeleteRows: 'batch_delete_rows', + updateRowsByFilter: 'update_rows_by_filter', + deleteRowsByFilter: 'delete_rows_by_filter', +} as const + +export type TableRowsOperation = (typeof TableRowsOperation)[keyof typeof TableRowsOperation] + +export const TableRowsOperationValues = [ + TableRowsOperation.insertRow, + TableRowsOperation.batchInsertRows, + TableRowsOperation.updateRow, + TableRowsOperation.batchUpdateRows, + TableRowsOperation.deleteRow, + TableRowsOperation.batchDeleteRows, + TableRowsOperation.updateRowsByFilter, + TableRowsOperation.deleteRowsByFilter, +] as const + export const TerminalOperation = { run: 'run', read: 'read', @@ -6008,6 +6612,11 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [TableAutomations.id]: TableAutomations, + [TableColumns.id]: TableColumns, + [TableEnrichments.id]: TableEnrichments, + [TableManage.id]: TableManage, + [TableRows.id]: TableRows, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index ccc7a598c0..a3fd31e1c8 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1971,7 +1971,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4749,6 +4749,579 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + table_automations: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the group runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { + type: 'string', + }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'New source block ID for this column.', + }, + columnName: { + type: 'string', + description: + 'The existing output column to remap (must be bound to this group).', + }, + path: { + type: 'string', + description: 'New dotted output path on the new block.', + }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Source block ID inside the workflow.', + }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { + type: 'string', + description: 'Dotted output path on the block.', + }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { + type: 'string', + description: 'Row ID for cancel_table_runs with scope "row".', + }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { + type: 'string', + }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: + 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_columns: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { + type: 'string', + description: 'New column name (required for rename_column)', + }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { + type: 'string', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_enrichments: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for add_enrichment)', + }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_manage: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { + type: 'string', + description: 'Table description (optional for create)', + }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { + type: 'string', + description: 'Table name (required for create)', + }, + newName: { + type: 'string', + description: 'New table name (required for rename)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_rows: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { + type: 'string', + description: 'Row ID (required for update_row, delete_row)', + }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { + type: 'string', + }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + 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)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, terminal: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 2eb7b1d681..7d87b43221 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -48,6 +48,11 @@ import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/genera import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' +import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' +import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -172,6 +177,11 @@ const baseServerToolRegistry: Record = { [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, [queryUserTableServerTool.name]: queryUserTableServerTool, + [tableManageServerTool.name]: tableManageServerTool, + [tableRowsServerTool.name]: tableRowsServerTool, + [tableColumnsServerTool.name]: tableColumnsServerTool, + [tableAutomationsServerTool.name]: tableAutomationsServerTool, + [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/table-automations.ts b/apps/sim/lib/copilot/tools/server/table/table-automations.ts new file mode 100644 index 0000000000..e2b9492940 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-automations.ts @@ -0,0 +1,49 @@ +import { TableAutomations } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableAutomationsArgs = { + operation: string + args?: Record +} + +type TableAutomationsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', +]) + +/** + * per-row workflow automations slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableAutomationsServerTool: BaseServerTool< + TableAutomationsArgs, + TableAutomationsResult +> = { + name: TableAutomations.id, + async execute(params: TableAutomationsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_automations does not support operation '${operation}' (allowed: list_workflow_outputs, add_workflow_group, update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output, run_column, cancel_table_runs); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-columns.ts b/apps/sim/lib/copilot/tools/server/table/table-columns.ts new file mode 100644 index 0000000000..9e4ebc2f49 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-columns.ts @@ -0,0 +1,42 @@ +import { TableColumns } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableColumnsArgs = { + operation: string + args?: Record +} + +type TableColumnsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'add_column', + 'rename_column', + 'delete_column', + 'update_column', +]) + +/** + * column DDL (add/rename/retype/delete) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableColumnsServerTool: BaseServerTool = { + name: TableColumns.id, + async execute(params: TableColumnsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_columns does not support operation '${operation}' (allowed: add_column, rename_column, delete_column, update_column); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts new file mode 100644 index 0000000000..caa317ebdf --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts @@ -0,0 +1,40 @@ +import { TableEnrichments } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableEnrichmentsArgs = { + operation: string + args?: Record +} + +type TableEnrichmentsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['list_enrichments', 'add_enrichment']) + +/** + * prebuilt per-row enrichments slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableEnrichmentsServerTool: BaseServerTool< + TableEnrichmentsArgs, + TableEnrichmentsResult +> = { + name: TableEnrichments.id, + async execute(params: TableEnrichmentsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_enrichments does not support operation '${operation}' (allowed: list_enrichments, add_enrichment); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-manage.ts b/apps/sim/lib/copilot/tools/server/table/table-manage.ts new file mode 100644 index 0000000000..b8030fc5f4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-manage.ts @@ -0,0 +1,37 @@ +import { TableManage } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableManageArgs = { + operation: string + args?: Record +} + +type TableManageResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['create', 'create_from_file', 'import_file', 'rename']) + +/** + * table lifecycle (create, create_from_file, import_file, rename) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableManageServerTool: BaseServerTool = { + name: TableManage.id, + async execute(params: TableManageArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_manage does not support operation '${operation}' (allowed: create, create_from_file, import_file, rename); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-rows.ts b/apps/sim/lib/copilot/tools/server/table/table-rows.ts new file mode 100644 index 0000000000..aec5ee0821 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-rows.ts @@ -0,0 +1,46 @@ +import { TableRows } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableRowsArgs = { + operation: string + args?: Record +} + +type TableRowsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', +]) + +/** + * row data (insert/update/delete, batch and by-filter) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableRowsServerTool: BaseServerTool = { + name: TableRows.id, + async execute(params: TableRowsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_rows does not support operation '${operation}' (allowed: insert_row, batch_insert_rows, update_row, batch_update_rows, delete_row, batch_delete_rows, update_rows_by_filter, delete_rows_by_filter); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-split.test.ts b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts new file mode 100644 index 0000000000..00233836d3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const executeUserTable = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ + userTableServerTool: { execute: executeUserTable }, +})) + +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' +import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' +import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' + +/** + * Every split tool delegates its own operations to the shared user_table + * executor untouched, and rejects operations that belong to a sibling slice + * without ever invoking it — the per-slice allowlist is the access contract. + */ +describe('split table tools', () => { + beforeEach(() => { + vi.clearAllMocks() + executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) + }) + + const cases = [ + { tool: tableManageServerTool, own: 'create', foreign: 'insert_row' }, + { tool: tableRowsServerTool, own: 'batch_update_rows', foreign: 'add_column' }, + { tool: tableColumnsServerTool, own: 'update_column', foreign: 'create' }, + { tool: tableAutomationsServerTool, own: 'run_column', foreign: 'add_enrichment' }, + { tool: tableEnrichmentsServerTool, own: 'add_enrichment', foreign: 'run_column' }, + ] as const + + it.each(cases)( + '$tool.name delegates $own and rejects $foreign', + async ({ tool, own, foreign }) => { + const context = { userId: 'user-1', workspaceId: 'workspace-1', copilotToolExecution: true } + const params = { operation: own, args: { tableId: 'table-1' } } + + await expect(tool.execute(params as never, context as never)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeUserTable).toHaveBeenCalledWith(params, context) + + executeUserTable.mockClear() + await expect( + tool.execute({ operation: foreign, args: { tableId: 'table-1' } } as never) + ).resolves.toMatchObject({ + success: false, + message: expect.stringContaining(foreign), + }) + expect(executeUserTable).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 9d3e30c37c..f15c80c522 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -444,6 +444,11 @@ const TOOL_TITLES: Record = { user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', + table_manage: 'Managing table', + table_rows: 'Editing table rows', + table_columns: 'Editing table columns', + table_automations: 'Managing table automations', + table_enrichments: 'Managing table enrichments', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', @@ -682,6 +687,11 @@ export function getToolDisplayTitle(name: string, args?: Record case 'knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': + case 'table_manage': + case 'table_rows': + case 'table_columns': + case 'table_automations': + case 'table_enrichments': return queryUserTableTitle(args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args)