fix(copilot): mount input tables with display-name CSV headers, not column IDs (#5121)

This commit is contained in:
Theodore Li
2026-06-17 17:45:47 -04:00
committed by GitHub
parent fcfa41cd42
commit 08bcacd74e
2 changed files with 56 additions and 23 deletions
@@ -89,7 +89,8 @@ describe('executeFunctionExecute table mounts', () => {
mockExecuteTool.mockResolvedValue({ success: true })
mockGetTableById.mockResolvedValue(table)
mockIsFeatureEnabled.mockResolvedValue(false)
mockQueryRows.mockResolvedValue({ rows: [{ data: { name: 'Ada' } }] })
// Row data is keyed by stable column id at rest, not display name.
mockQueryRows.mockResolvedValue({ rows: [{ data: { col_name: 'Ada' } }] })
mockHasCloudStorage.mockReturnValue(true)
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/presigned?sig=abc')
})
@@ -104,6 +105,53 @@ describe('executeFunctionExecute table mounts', () => {
expect(files[0].content).toBe('name\nAda')
})
it('mounts CSV with display-name headers and id-keyed values, never column ids', async () => {
mockGetTableById.mockResolvedValue({
id: 'tbl_2',
workspaceId: 'ws_1',
rowCount: 2,
schema: {
columns: [
{ id: 'col_name', name: 'name', type: 'string' },
{ id: 'col_company', name: 'company', type: 'string' },
],
},
})
mockQueryRows.mockResolvedValue({
rows: [
{ data: { col_name: 'Ada', col_company: 'Analytical Engine' } },
{ data: { col_name: 'Grace', col_company: 'Navy, Inc' } },
],
})
await executeFunctionExecute({ inputTables: ['tbl_2'] }, context as never)
const csv = mountedFiles()[0].content as string
const lines = csv.split('\n')
expect(lines[0]).toBe('name,company')
expect(lines[1]).toBe('Ada,Analytical Engine')
// Value containing a comma is quoted.
expect(lines[2]).toBe('Grace,"Navy, Inc"')
// No stable column id leaks into the mounted file.
expect(csv).not.toContain('col_name')
expect(csv).not.toContain('col_company')
})
it('reads values by column id for legacy name-keyed rows too', async () => {
// Legacy column with no id: getColumnId falls back to name, so name-keyed data is correct.
mockGetTableById.mockResolvedValue({
id: 'tbl_legacy',
workspaceId: 'ws_1',
rowCount: 1,
schema: { columns: [{ name: 'email', type: 'string' }] },
})
mockQueryRows.mockResolvedValue({ rows: [{ data: { email: 'a@b.com' } }] })
await executeFunctionExecute({ inputTables: ['tbl_legacy'] }, context as never)
expect(mountedFiles()[0].content).toBe('email\na@b.com')
})
it('flag ON + cloud storage: mounts by presigned URL, no bytes through web', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
mockGetOrCreateTableSnapshot.mockResolvedValue({
@@ -3,6 +3,8 @@ import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
import { queryRows } from '@/lib/table/rows/service'
import { getTableById, listTables } from '@/lib/table/service'
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
@@ -80,7 +82,7 @@ async function resolveTableRef(
return tablePathLookup?.get(tableName) ?? null
}
async function resolveInputFiles(
export async function resolveInputFiles(
workspaceId: string,
inputFiles?: unknown[],
inputTables?: unknown[],
@@ -333,28 +335,11 @@ async function resolveInputFiles(
const rows = await queryRows(table, {}, 'copilot-fn-exec')
const allKeys = new Set(table.schema.columns.map((column) => column.name))
for (const row of rows.rows ?? []) {
if (row.data && typeof row.data === 'object') {
for (const key of Object.keys(row.data as Record<string, unknown>)) {
allKeys.add(key)
}
}
}
const headers = Array.from(allKeys)
const csvLines = [headers.join(',')]
for (const row of rows.rows ?? []) {
const data = (row.data || {}) as Record<string, unknown>
const columns = table.schema.columns
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
for (const row of rows.rows) {
csvLines.push(
headers
.map((h) => {
const val = data[h]
const str = val === null || val === undefined ? '' : String(val)
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str
})
.join(',')
toCsvRow(columns.map((column) => formatCsvValue(row.data[getColumnId(column)])))
)
}
const csvContent = csvLines.join('\n')