Support split table tools and option recovery

This commit is contained in:
Siddharth Ganesan
2026-08-12 19:21:42 -07:00
parent b5bae34edf
commit eaee669c85
12 changed files with 1551 additions and 2 deletions
@@ -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 <options>${bareOptions}</options>`, false)
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
})
})
@@ -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
* `<options>` 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<ContentSegment, { type: 'text' }>
@@ -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 { "<outputId>": "<columnName>" }; 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<string, ToolCatalogEntry> = {
[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,
@@ -1971,7 +1971,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
},
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 { "<outputId>": "<columnName>" }; 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',
@@ -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<string, BaseServerTool> = {
[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,
@@ -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<string, any>
}
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)
},
}
@@ -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<string, any>
}
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<TableColumnsArgs, TableColumnsResult> = {
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)
},
}
@@ -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<string, any>
}
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)
},
}
@@ -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<string, any>
}
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<TableManageArgs, TableManageResult> = {
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)
},
}
@@ -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<string, any>
}
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<TableRowsArgs, TableRowsResult> = {
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)
},
}
@@ -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()
}
)
})
@@ -444,6 +444,11 @@ const TOOL_TITLES: Record<string, string> = {
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<string, unknown>
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)