Skip to content

Commit 3f2e51a

Browse files
fix(copilot): mount input tables with display-name CSV headers, not column IDs
1 parent d7fd040 commit 3f2e51a

2 files changed

Lines changed: 125 additions & 22 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetTableById, mockListTables, mockQueryRows, mockIsFeatureEnabled } = vi.hoisted(
7+
() => ({
8+
mockGetTableById: vi.fn(),
9+
mockListTables: vi.fn(),
10+
mockQueryRows: vi.fn(),
11+
mockIsFeatureEnabled: vi.fn(),
12+
})
13+
)
14+
15+
vi.mock('@/lib/table/service', () => ({
16+
getTableById: mockGetTableById,
17+
listTables: mockListTables,
18+
}))
19+
20+
vi.mock('@/lib/table/rows/service', () => ({
21+
queryRows: mockQueryRows,
22+
}))
23+
24+
vi.mock('@/lib/core/config/feature-flags', () => ({
25+
isFeatureEnabled: mockIsFeatureEnabled,
26+
}))
27+
28+
vi.mock('@/tools', () => ({
29+
executeTool: vi.fn(),
30+
}))
31+
32+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
33+
fetchWorkspaceFileBuffer: vi.fn(),
34+
findWorkspaceFileRecord: vi.fn(),
35+
getSandboxWorkspaceFilePath: vi.fn(),
36+
listWorkspaceFiles: vi.fn(),
37+
}))
38+
39+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
40+
listWorkspaceFileFolders: vi.fn(),
41+
}))
42+
43+
vi.mock('@/lib/copilot/vfs/path-utils', () => ({
44+
decodeVfsPathSegments: vi.fn(),
45+
encodeVfsPathSegments: vi.fn(),
46+
}))
47+
48+
vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({
49+
resolveWorkflowAliasForWorkspace: vi.fn(),
50+
}))
51+
52+
vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({
53+
isPlanAliasPath: vi.fn().mockReturnValue(false),
54+
workflowAliasSandboxPath: vi.fn(),
55+
}))
56+
57+
import { resolveInputFiles } from '@/lib/copilot/tools/handlers/function-execute'
58+
59+
describe('resolveInputFiles — table mount', () => {
60+
beforeEach(() => {
61+
vi.clearAllMocks()
62+
mockIsFeatureEnabled.mockResolvedValue(false)
63+
})
64+
65+
it('mounts a CSV with display-name headers and id-keyed values, never column ids', async () => {
66+
mockGetTableById.mockResolvedValue({
67+
id: 'tbl_123',
68+
workspaceId: 'ws_1',
69+
name: 'people',
70+
schema: {
71+
columns: [
72+
{ id: 'col_name', name: 'name', type: 'text' },
73+
{ id: 'col_company', name: 'company', type: 'text' },
74+
],
75+
},
76+
})
77+
mockQueryRows.mockResolvedValue({
78+
rows: [
79+
{ id: 'r1', data: { col_name: 'Ada', col_company: 'Analytical Engine' } },
80+
{ id: 'r2', data: { col_name: 'Grace', col_company: 'Navy, Inc' } },
81+
],
82+
})
83+
84+
const files = await resolveInputFiles('ws_1', undefined, ['tbl_123'])
85+
86+
expect(files).toHaveLength(1)
87+
const csv = files[0].content
88+
const lines = csv.split('\n')
89+
90+
expect(lines[0]).toBe('name,company')
91+
expect(lines[1]).toBe('Ada,Analytical Engine')
92+
// Value containing a comma is quoted.
93+
expect(lines[2]).toBe('Grace,"Navy, Inc"')
94+
// No stable column id leaks into the mounted file.
95+
expect(csv).not.toContain('col_name')
96+
expect(csv).not.toContain('col_company')
97+
expect(files[0].path).toBe('/home/user/tables/tbl_123.csv')
98+
})
99+
100+
it('reads values by column id for legacy name-keyed rows too', async () => {
101+
mockGetTableById.mockResolvedValue({
102+
id: 'tbl_legacy',
103+
workspaceId: 'ws_1',
104+
name: 'legacy',
105+
schema: {
106+
// Legacy column with no id: getColumnId falls back to name.
107+
columns: [{ name: 'email', type: 'text' }],
108+
},
109+
})
110+
mockQueryRows.mockResolvedValue({
111+
rows: [{ id: 'r1', data: { email: 'a@b.com' } }],
112+
})
113+
114+
const files = await resolveInputFiles('ws_1', undefined, ['tbl_legacy'])
115+
116+
expect(files[0].content).toBe('email\na@b.com')
117+
})
118+
})

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 { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
@@ -63,7 +65,7 @@ async function resolveTableRef(
6365
return tablePathLookup?.get(tableName) ?? null
6466
}
6567

66-
async function resolveInputFiles(
68+
export async function resolveInputFiles(
6769
workspaceId: string,
6870
inputFiles?: unknown[],
6971
inputTables?: unknown[],
@@ -265,28 +267,11 @@ async function resolveInputFiles(
265267
}
266268
const rows = await queryRows(table, {}, 'copilot-fn-exec')
267269

268-
const allKeys = new Set(table.schema.columns.map((column) => column.name))
269-
for (const row of rows.rows ?? []) {
270-
if (row.data && typeof row.data === 'object') {
271-
for (const key of Object.keys(row.data as Record<string, unknown>)) {
272-
allKeys.add(key)
273-
}
274-
}
275-
}
276-
const headers = Array.from(allKeys)
277-
const csvLines = [headers.join(',')]
278-
for (const row of rows.rows ?? []) {
279-
const data = (row.data || {}) as Record<string, unknown>
270+
const columns = table.schema.columns
271+
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
272+
for (const row of rows.rows) {
280273
csvLines.push(
281-
headers
282-
.map((h) => {
283-
const val = data[h]
284-
const str = val === null || val === undefined ? '' : String(val)
285-
return str.includes(',') || str.includes('"') || str.includes('\n')
286-
? `"${str.replace(/"/g, '""')}"`
287-
: str
288-
})
289-
.join(',')
274+
toCsvRow(columns.map((column) => formatCsvValue(row.data[getColumnId(column)])))
290275
)
291276
}
292277
const csvContent = csvLines.join('\n')

0 commit comments

Comments
 (0)