Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/sim/lib/copilot/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export async function buildCopilotRequestPayload(
const filename = (f.filename ?? f.name ?? 'file') as string
const mediaType = (f.media_type ?? f.mimeType ?? 'application/octet-stream') as string
try {
await trackChatUpload(
const { displayName } = await trackChatUpload(
params.workspaceId,
userId,
chatId,
Expand All @@ -248,13 +248,13 @@ export async function buildCopilotRequestPayload(
f.size
)
const lines = [
`File "${filename}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${filename}")`,
`To save permanently: materialize_file(fileName: "${filename}")`,
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${displayName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (filename.endsWith('.json')) {
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${filename}", operation: "import")`
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
}
uploadContexts.push({
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/copilot/tools/handlers/materialize-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
return {
id: row.id,
workspaceId: row.workspaceId || '',
name: row.originalName,
name: row.displayName ?? row.originalName,
key: row.key,
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
size: row.size,
Expand All @@ -45,7 +45,7 @@ async function executeSave(fileName: string, chatId: string): Promise<ToolCallRe

const [updated] = await db
.update(workspaceFiles)
.set({ context: 'workspace', chatId: null })
.set({ context: 'workspace', chatId: null, originalName: row.displayName ?? row.originalName })
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })

Expand Down
179 changes: 179 additions & 0 deletions apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* @vitest-environment node
*/

import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/db', () => dbChainMock)

const { mockReadFileRecord } = vi.hoisted(() => ({
mockReadFileRecord: vi.fn(),
}))

vi.mock('@/lib/copilot/vfs/file-reader', () => ({
readFileRecord: mockReadFileRecord,
}))

import {
findMothershipUploadRowByChatAndName,
listChatUploads,
readChatUpload,
} from './upload-file-reader'

const CHAT_ID = '11111111-1111-1111-1111-111111111111'
const NOW = new Date('2026-05-05T00:00:00.000Z')

function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'wf_1',
key: 'mothership/abc/123-image.png',
userId: 'user_1',
workspaceId: 'ws_1',
context: 'mothership',
chatId: CHAT_ID,
originalName: 'image.png',
displayName: 'image.png',
contentType: 'image/png',
size: 1024,
deletedAt: null,
uploadedAt: NOW,
updatedAt: NOW,
...overrides,
}
}

/**
* Resolver chain is `.where().orderBy(...).limit(1)`. The default chain mock makes
* `orderBy` a terminal, so we wire a chainable `{limit}` for each call manually.
*/
function mockOrderByThenLimit(rows: unknown) {
dbChainMockFns.orderBy.mockReturnValueOnce({ limit: dbChainMockFns.limit } as never)
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
}

describe('findMothershipUploadRowByChatAndName', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

it('matches by displayName for the first occurrence', async () => {
const row = makeRow({ id: 'wf_1', displayName: 'image.png' })
mockOrderByThenLimit([row])

const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png')

expect(result).toEqual(row)
})

it('matches by suffixed displayName for collision-disambiguated rows', async () => {
const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' })
mockOrderByThenLimit([row])

const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image (2).png')

expect(result?.id).toBe('wf_2')
expect(result?.displayName).toBe('image (2).png')
})

it('prefers the most recent row when legacy rows share the same originalName', async () => {
// Pre-displayName legacy rows have displayName=null. Resolver's ORDER BY uploaded_at
// DESC ensures the newest upload wins, fixing read("uploads/<name>") for legacy data.
const newer = makeRow({
id: 'wf_new',
displayName: null,
originalName: 'image.png',
uploadedAt: new Date('2026-05-05T12:00:00.000Z'),
})
mockOrderByThenLimit([newer])

const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png')

expect(result?.id).toBe('wf_new')
})

it('returns null when no row matches and the fallback scan is empty', async () => {
// First query: .where().orderBy().limit() returns [].
mockOrderByThenLimit([])
// Second query: .where().orderBy(...) (no .limit) — orderBy is the terminal.
dbChainMockFns.orderBy.mockResolvedValueOnce([] as never)

const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'missing.png')

expect(result).toBeNull()
})

it('falls back to normalized segment match when exact lookup misses (macOS U+202F)', async () => {
// Model passes ASCII space; DB row was saved with U+202F (narrow no-break space).
const macosName = 'Screenshot 2026-05-05 at 9.41.00 AM.png'
const asciiName = 'Screenshot 2026-05-05 at 9.41.00 AM.png'
const row = makeRow({ id: 'wf_3', displayName: macosName })

mockOrderByThenLimit([])
dbChainMockFns.orderBy.mockResolvedValueOnce([row] as never)

const result = await findMothershipUploadRowByChatAndName(CHAT_ID, asciiName)

expect(result?.id).toBe('wf_3')
})
})

describe('listChatUploads', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

it('returns rows in upload order with name set to displayName', async () => {
const rows = [
makeRow({ id: 'a', displayName: 'image.png' }),
makeRow({ id: 'b', displayName: 'image (2).png' }),
makeRow({ id: 'c', displayName: 'image (3).png' }),
]
dbChainMockFns.orderBy.mockResolvedValueOnce(rows)

const result = await listChatUploads(CHAT_ID)

expect(result.map((r) => r.id)).toEqual(['a', 'b', 'c'])
expect(result.map((r) => r.name)).toEqual(['image.png', 'image (2).png', 'image (3).png'])
expect(result.every((r) => r.storageContext === 'mothership')).toBe(true)
})

it('returns [] and does not throw when the DB query fails', async () => {
dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('boom'))
const result = await listChatUploads(CHAT_ID)
expect(result).toEqual([])
})
})

describe('readChatUpload', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockReadFileRecord.mockReset()
})

it('reads the row resolved by the suffixed displayName', async () => {
const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' })
mockOrderByThenLimit([row])
mockReadFileRecord.mockResolvedValueOnce({ content: 'PNGDATA', totalLines: 1 })

const result = await readChatUpload('image (2).png', CHAT_ID)

expect(result).toEqual({ content: 'PNGDATA', totalLines: 1 })
expect(mockReadFileRecord).toHaveBeenCalledWith(
expect.objectContaining({ id: 'wf_2', name: 'image (2).png', storageContext: 'mothership' })
)
})

it('returns null when no row matches', async () => {
mockOrderByThenLimit([])
dbChainMockFns.orderBy.mockResolvedValueOnce([] as never)

const result = await readChatUpload('nope.png', CHAT_ID)

expect(result).toBeNull()
expect(mockReadFileRecord).not.toHaveBeenCalled()
})
})
31 changes: 24 additions & 7 deletions apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ import { db } from '@sim/db'
import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import { and, asc, desc, eq, isNull, or } from 'drizzle-orm'
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import { getServePathPrefix } from '@/lib/uploads'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'

const logger = createLogger('UploadFileReader')

/** VFS-visible name. Coalesces to originalName for legacy rows that predate displayName. */
function vfsName(row: typeof workspaceFiles.$inferSelect): string {
return row.displayName ?? row.originalName
}

function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): WorkspaceFileRecord {
const pathPrefix = getServePathPrefix()
return {
id: row.id,
workspaceId: row.workspaceId || '',
name: row.originalName,
name: vfsName(row),
key: row.key,
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
size: row.size,
Expand All @@ -29,8 +34,14 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa
}

/**
* Resolve a mothership upload row by `originalName`, preferring an exact DB match (limit 1) and
* only scanning all chat uploads when that misses (e.g. macOS U+202F vs ASCII space in the name).
* Resolve a mothership upload row by VFS name (the collision-disambiguated `displayName`
* for new rows, or `originalName` for legacy rows that predate the column). Prefers an
* exact DB match; falls back to a normalized scan when the model passes a visually
* equivalent name (e.g. macOS U+202F vs ASCII space in screenshot filenames).
*
* On ambiguity (multiple legacy rows sharing the same originalName in one chat — the
* pre-displayName collision case), returns the most recent upload. New rows are unique
* by index so this only affects pre-fix data.
*/
export async function findMothershipUploadRowByChatAndName(
chatId: string,
Expand All @@ -43,10 +54,14 @@ export async function findMothershipUploadRowByChatAndName(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
eq(workspaceFiles.originalName, fileName),
or(
eq(workspaceFiles.displayName, fileName),
and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, fileName))
),
isNull(workspaceFiles.deletedAt)
)
)
.orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id))
.limit(1)

if (exactRows[0]) {
Expand All @@ -63,13 +78,14 @@ export async function findMothershipUploadRowByChatAndName(
isNull(workspaceFiles.deletedAt)
)
)
.orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id))

const segmentKey = normalizeVfsSegment(fileName)
return allRows.find((r) => normalizeVfsSegment(r.originalName) === segmentKey) ?? null
return allRows.find((r) => normalizeVfsSegment(vfsName(r)) === segmentKey) ?? null
}

/**
* List all chat-scoped uploads for a given chat.
* List all chat-scoped uploads for a given chat in upload order.
*/
export async function listChatUploads(chatId: string): Promise<WorkspaceFileRecord[]> {
try {
Expand All @@ -83,6 +99,7 @@ export async function listChatUploads(chatId: string): Promise<WorkspaceFileReco
isNull(workspaceFiles.deletedAt)
)
)
.orderBy(asc(workspaceFiles.uploadedAt), asc(workspaceFiles.id))

return rows.map(toWorkspaceFileRecord)
} catch (err) {
Expand Down
Loading
Loading