Skip to content
Open
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
11 changes: 6 additions & 5 deletions apps/sim/lib/copilot/generated/tool-catalog-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,12 +1305,12 @@ export const CreateFile: ToolCatalogEntry = {
contentType: {
type: 'string',
description:
'Optional MIME type override. Usually omit and let the system infer from the file extension.',
'MIME type of the file when using the backward-compatible fileName parameter. Prefer outputs.files[0].mimeType for new calls.',
},
fileName: {
type: 'string',
description:
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.',
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls; when using fileName, contentType is required.',
},
outputs: {
type: 'object',
Expand All @@ -1325,7 +1325,8 @@ export const CreateFile: ToolCatalogEntry = {
properties: {
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
description:
'Required MIME type of the file, e.g. "text/markdown" for Markdown. This sets the file\'s stored type — the source of truth for how the file is treated. The extension in the name is cosmetic only and never determines the type.',
},
mode: {
type: 'string',
Expand All @@ -1337,7 +1338,7 @@ export const CreateFile: ToolCatalogEntry = {
description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".',
},
},
required: ['path', 'mode'],
required: ['path', 'mode', 'mimeType'],
},
},
},
Expand Down Expand Up @@ -5470,7 +5471,7 @@ export const WorkspaceFile: ToolCatalogEntry = {
contentType: {
type: 'string',
description:
'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
"Optional MIME type override. Omit to keep the file's existing stored type; pass only to deliberately change it.",
enum: [
'text/markdown',
'text/html',
Expand Down
11 changes: 6 additions & 5 deletions apps/sim/lib/copilot/generated/tool-schemas-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1175,12 +1175,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
contentType: {
type: 'string',
description:
'Optional MIME type override. Usually omit and let the system infer from the file extension.',
'MIME type of the file when using the backward-compatible fileName parameter. Prefer outputs.files[0].mimeType for new calls.',
},
fileName: {
type: 'string',
description:
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.',
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls; when using fileName, contentType is required.',
},
outputs: {
type: 'object',
Expand All @@ -1195,7 +1195,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
properties: {
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
description:
'Required MIME type of the file, e.g. "text/markdown" for Markdown. This sets the file\'s stored type — the source of truth for how the file is treated. The extension in the name is cosmetic only and never determines the type.',
},
mode: {
type: 'string',
Expand All @@ -1207,7 +1208,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".',
},
},
required: ['path', 'mode'],
required: ['path', 'mode', 'mimeType'],
},
},
},
Expand Down Expand Up @@ -5337,7 +5338,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
contentType: {
type: 'string',
description:
'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
"Optional MIME type override. Omit to keep the file's existing stored type; pass only to deliberately change it.",
enum: [
'text/markdown',
'text/html',
Expand Down
95 changes: 95 additions & 0 deletions apps/sim/lib/copilot/tools/server/files/create-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/copilot/tools/handlers/access', () => ({
ensureWorkspaceAccess: mockEnsureWorkspaceAccess,
}))
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
}))

import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file'

const context = { userId: 'user-1', workspaceId: 'ws-1' }

describe('createFileServerTool required MIME', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnsureWorkspaceAccess.mockResolvedValue({ role: 'admin' })
mockWriteWorkspaceFileByPath.mockResolvedValue({
id: 'file-1',
name: 'notes.md',
vfsPath: 'files/notes.md',
})
})

it('fails without a declared MIME instead of inferring from the extension', async () => {
const result = await createFileServerTool.execute(
{ outputs: { files: [{ path: 'files/notes.md', mode: 'create' }] } } as never,
context
)

expect(result.success).toBe(false)
expect(result.message).toContain('requires an explicit MIME type')
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})

it('rejects a malformed MIME instead of storing it verbatim', async () => {
const result = await createFileServerTool.execute(
{
outputs: { files: [{ path: 'files/notes.md', mode: 'create', mimeType: 'markdown' }] },
} as never,
context
)

expect(result.success).toBe(false)
expect(result.message).toContain('Invalid MIME type "markdown"')
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})

it('normalizes casing and parameters before the MIME becomes the stored type', async () => {
const result = await createFileServerTool.execute(
{
outputs: {
files: [
{ path: 'files/notes.md', mode: 'create', mimeType: 'TEXT/MARKDOWN; charset=UTF-8' },
],
},
} as never,
context
)

expect(result.success).toBe(true)
const args = mockWriteWorkspaceFileByPath.mock.calls[0][0]
expect(args.inferredMimeType).toBe('text/markdown')
expect(args.target.mimeType).toBe('text/markdown')
expect(result.data?.contentType).toBe('text/markdown')
})

it('accepts the legacy fileName + contentType combination', async () => {
const result = await createFileServerTool.execute(
{ fileName: 'notes.md', contentType: 'text/markdown' } as never,
context
)

expect(result.success).toBe(true)
const args = mockWriteWorkspaceFileByPath.mock.calls[0][0]
expect(args.target.path).toBe('files/notes.md')
expect(args.target.mimeType).toBeUndefined()
expect(args.inferredMimeType).toBe('text/markdown')
})

it('still requires a path or fileName', async () => {
const result = await createFileServerTool.execute({} as never, context)

expect(result.success).toBe(false)
expect(result.message).toContain('outputs.files[0].path or fileName')
})
})
32 changes: 29 additions & 3 deletions apps/sim/lib/copilot/tools/server/files/create-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ import {
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
import { inferContentType } from './workspace-file'

const logger = createLogger('CreateFileServerTool')
const CREATE_FILE_TOOL_ID = 'create_file'

const MIME_SHAPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/

/**
* Normalizes a model-declared MIME before it becomes the stored (load-bearing) file type:
* strips parameters (";charset=..."), trims, lowercases, and rejects anything that is not
* a bare type/subtype token pair. Returns null when the value cannot be a MIME at all, so
* the caller can fail with an instructive message instead of persisting junk verbatim.
*/
function normalizeDeclaredMime(raw: string): string | null {
const bare = raw.split(';')[0].trim().toLowerCase()
return MIME_SHAPE.test(bare) ? bare : null
}

interface CreateFileArgs {
fileName: string
contentType?: string
Expand Down Expand Up @@ -50,7 +62,21 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
}
const outputPath =
outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`)
const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType)
const declaredType = outputFile?.mimeType ?? explicitType
if (!declaredType) {
return {
success: false,
message:
'create_file requires an explicit MIME type: pass outputs.files[0].mimeType (e.g. "text/markdown"), or contentType when using the legacy fileName parameter. The MIME type is the source of truth for the file\'s type — the extension in the name is cosmetic and never determines it.',
}
}
const contentType = normalizeDeclaredMime(declaredType)
if (!contentType) {
return {
success: false,
message: `Invalid MIME type "${declaredType}": pass a full type/subtype MIME such as "text/markdown" or "application/json". It becomes the file's stored type, so a malformed value would break how the file is treated everywhere.`,
}
}
const emptyBuffer = Buffer.from('', 'utf-8')

assertServerToolNotAborted(context)
Expand All @@ -61,7 +87,7 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
target: {
path: outputPath,
mode: outputFile?.mode ?? 'create',
mimeType: outputFile?.mimeType,
mimeType: outputFile?.mimeType ? contentType : undefined,
},
buffer: emptyBuffer,
inferredMimeType: contentType,
Expand Down
126 changes: 126 additions & 0 deletions apps/sim/lib/copilot/tools/server/files/edit-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: false }))
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
WorkspaceFile: { id: 'workspace_file' },
}))
vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkspaceAccess: vi.fn() }))
vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: vi.fn() }))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
fetchWorkspaceFileBuffer: vi.fn(),
getWorkspaceFile: vi.fn(),
resolveWorkspaceFileReference: vi.fn(),
updateWorkspaceFileContent: mockUpdateWorkspaceFileContent,
}))
vi.mock('@/lib/workspace-files/orchestration', () => ({
performDeleteWorkspaceFileItems: vi.fn(),
performRenameWorkspaceFile: vi.fn(),
}))
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
compileDoc: vi.fn(),
getE2BDocFormat: vi.fn(async () => null),
DocCompileUserError: class DocCompileUserError extends Error {},
DOCXJS_SOURCE_MIME: 'text/x-docxjs',
PPTXGENJS_SOURCE_MIME: 'text/x-pptxgenjs',
}))
vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
buildEmbeddedImageRefWarning: vi.fn(async () => ''),
}))
vi.mock('@/lib/copilot/tools/server/files/file-intent-store', () => ({
consumeLatestFileIntent: mockConsumeLatestFileIntent,
storeFileIntent: vi.fn(),
}))

import { editContentServerTool } from '@/lib/copilot/tools/server/files/edit-content'

/** Extension-less markdown file — the exact shape of the md->txt reversion regression. */
const markdownRecord = {
id: 'file-1',
workspaceId: 'ws-1',
name: 'new-boi',
key: 'workspace/ws-1/1-abc-new-boi',
path: '/api/files/serve/workspace/ws-1/1-abc-new-boi',
size: 10,
type: 'text/markdown',
uploadedBy: 'user-1',
uploadedAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
}

const context = { userId: 'user-1', workspaceId: 'ws-1' }

function intentWith(overrides: Record<string, unknown>) {
return {
operation: 'update',
fileId: markdownRecord.id,
workspaceId: 'ws-1',
userId: 'user-1',
fileRecord: markdownRecord,
createdAt: Date.now(),
...overrides,
}
}

describe('editContentServerTool stored-type preservation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpdateWorkspaceFileContent.mockResolvedValue(undefined)
})

it('preserves the stored type when the intent has no contentType (md->txt regression)', async () => {
mockConsumeLatestFileIntent.mockResolvedValue(intentWith({}))

const result = await editContentServerTool.execute({ content: '# hello' }, context)

expect(result.success).toBe(true)
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledTimes(1)
const [, , , , storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
expect(storedMime).toBe('text/markdown')
expect(result.data?.contentType).toBe('text/markdown')
})

it('applies an explicit intent contentType as a deliberate conversion', async () => {
mockConsumeLatestFileIntent.mockResolvedValue(intentWith({ contentType: 'text/html' }))

const result = await editContentServerTool.execute({ content: '<p>hi</p>' }, context)

expect(result.success).toBe(true)
const [, , , , storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
expect(storedMime).toBe('text/html')
})

it('preserves the stored type through a patch write', async () => {
mockConsumeLatestFileIntent.mockResolvedValue(
intentWith({
operation: 'patch',
existingContent: 'Hello World',
edit: { strategy: 'search_replace', search: 'World' },
})
)

const result = await editContentServerTool.execute({ content: 'Sim' }, context)

expect(result.success).toBe(true)
const [, , , buffer, storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
expect(buffer.toString('utf-8')).toBe('Hello Sim')
expect(storedMime).toBe('text/markdown')
})

it('fails with guidance when no workspace_file intent exists', async () => {
mockConsumeLatestFileIntent.mockResolvedValue(null)

const result = await editContentServerTool.execute({ content: 'x' }, context)

expect(result.success).toBe(false)
expect(result.message).toContain('workspace_file')
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
})
})
8 changes: 5 additions & 3 deletions apps/sim/lib/copilot/tools/server/files/edit-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/wor
import { getE2BDocFormat } from './doc-compile'
import { buildEmbeddedImageRefWarning } from './embedded-image-refs'
import { consumeLatestFileIntent } from './file-intent-store'
import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file'
import { compileDocForWrite, getDocumentFormatInfo } from './workspace-file'

const logger = createLogger('EditContentServerTool')

Expand Down Expand Up @@ -218,14 +218,16 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
}

// Compile once via the right engine (or isolated-vm fallback) and resolve
// the source MIME to store. Shared with the create path.
// the source MIME to store. Shared with the create path. Edits preserve the
// record's stored type — the load-bearing type lives in the type column,
// never in the name's extension — unless the caller explicitly converts.
const compiled = await compileDocForWrite({
source: finalContent,
fileName: fileRecord.name,
workspaceId,
ownerKey: `user:${context.userId}`,
signal: context.abortSignal,
fallbackMime: inferContentType(fileRecord.name, intent.contentType),
fallbackMime: intent.contentType || fileRecord.type,
})
if (!compiled.ok) {
return { success: false, message: compiled.message }
Expand Down
Loading
Loading