Skip to content

Commit 08bcacd

Browse files
fix(copilot): mount input tables with display-name CSV headers, not column IDs (#5121)
1 parent fcfa41c commit 08bcacd

2 files changed

Lines changed: 56 additions & 23 deletions

File tree

apps/sim/lib/copilot/tools/handlers/function-execute.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ describe('executeFunctionExecute table mounts', () => {
8989
mockExecuteTool.mockResolvedValue({ success: true })
9090
mockGetTableById.mockResolvedValue(table)
9191
mockIsFeatureEnabled.mockResolvedValue(false)
92-
mockQueryRows.mockResolvedValue({ rows: [{ data: { name: 'Ada' } }] })
92+
// Row data is keyed by stable column id at rest, not display name.
93+
mockQueryRows.mockResolvedValue({ rows: [{ data: { col_name: 'Ada' } }] })
9394
mockHasCloudStorage.mockReturnValue(true)
9495
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/presigned?sig=abc')
9596
})
@@ -104,6 +105,53 @@ describe('executeFunctionExecute table mounts', () => {
104105
expect(files[0].content).toBe('name\nAda')
105106
})
106107

108+
it('mounts CSV with display-name headers and id-keyed values, never column ids', async () => {
109+
mockGetTableById.mockResolvedValue({
110+
id: 'tbl_2',
111+
workspaceId: 'ws_1',
112+
rowCount: 2,
113+
schema: {
114+
columns: [
115+
{ id: 'col_name', name: 'name', type: 'string' },
116+
{ id: 'col_company', name: 'company', type: 'string' },
117+
],
118+
},
119+
})
120+
mockQueryRows.mockResolvedValue({
121+
rows: [
122+
{ data: { col_name: 'Ada', col_company: 'Analytical Engine' } },
123+
{ data: { col_name: 'Grace', col_company: 'Navy, Inc' } },
124+
],
125+
})
126+
127+
await executeFunctionExecute({ inputTables: ['tbl_2'] }, context as never)
128+
129+
const csv = mountedFiles()[0].content as string
130+
const lines = csv.split('\n')
131+
expect(lines[0]).toBe('name,company')
132+
expect(lines[1]).toBe('Ada,Analytical Engine')
133+
// Value containing a comma is quoted.
134+
expect(lines[2]).toBe('Grace,"Navy, Inc"')
135+
// No stable column id leaks into the mounted file.
136+
expect(csv).not.toContain('col_name')
137+
expect(csv).not.toContain('col_company')
138+
})
139+
140+
it('reads values by column id for legacy name-keyed rows too', async () => {
141+
// Legacy column with no id: getColumnId falls back to name, so name-keyed data is correct.
142+
mockGetTableById.mockResolvedValue({
143+
id: 'tbl_legacy',
144+
workspaceId: 'ws_1',
145+
rowCount: 1,
146+
schema: { columns: [{ name: 'email', type: 'string' }] },
147+
})
148+
mockQueryRows.mockResolvedValue({ rows: [{ data: { email: 'a@b.com' } }] })
149+
150+
await executeFunctionExecute({ inputTables: ['tbl_legacy'] }, context as never)
151+
152+
expect(mountedFiles()[0].content).toBe('email\na@b.com')
153+
})
154+
107155
it('flag ON + cloud storage: mounts by presigned URL, no bytes through web', async () => {
108156
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
109157
mockGetOrCreateTableSnapshot.mockResolvedValue({

apps/sim/lib/copilot/tools/handlers/function-execute.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/
33
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
44
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
55
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
6+
import { getColumnId } from '@/lib/table/column-keys'
7+
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
68
import { queryRows } from '@/lib/table/rows/service'
79
import { getTableById, listTables } from '@/lib/table/service'
810
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
@@ -80,7 +82,7 @@ async function resolveTableRef(
8082
return tablePathLookup?.get(tableName) ?? null
8183
}
8284

83-
async function resolveInputFiles(
85+
export async function resolveInputFiles(
8486
workspaceId: string,
8587
inputFiles?: unknown[],
8688
inputTables?: unknown[],
@@ -333,28 +335,11 @@ async function resolveInputFiles(
333335

334336
const rows = await queryRows(table, {}, 'copilot-fn-exec')
335337

336-
const allKeys = new Set(table.schema.columns.map((column) => column.name))
337-
for (const row of rows.rows ?? []) {
338-
if (row.data && typeof row.data === 'object') {
339-
for (const key of Object.keys(row.data as Record<string, unknown>)) {
340-
allKeys.add(key)
341-
}
342-
}
343-
}
344-
const headers = Array.from(allKeys)
345-
const csvLines = [headers.join(',')]
346-
for (const row of rows.rows ?? []) {
347-
const data = (row.data || {}) as Record<string, unknown>
338+
const columns = table.schema.columns
339+
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
340+
for (const row of rows.rows) {
348341
csvLines.push(
349-
headers
350-
.map((h) => {
351-
const val = data[h]
352-
const str = val === null || val === undefined ? '' : String(val)
353-
return str.includes(',') || str.includes('"') || str.includes('\n')
354-
? `"${str.replace(/"/g, '""')}"`
355-
: str
356-
})
357-
.join(',')
342+
toCsvRow(columns.map((column) => formatCsvValue(row.data[getColumnId(column)])))
358343
)
359344
}
360345
const csvContent = csvLines.join('\n')

0 commit comments

Comments
 (0)