diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9c996be921a..ff78ba30c88 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -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', @@ -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', @@ -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'], }, }, }, @@ -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', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e7745482158..cce5f4e941e 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1175,12 +1175,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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', @@ -1195,7 +1195,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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', @@ -1207,7 +1208,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', }, }, - required: ['path', 'mode'], + required: ['path', 'mode', 'mimeType'], }, }, }, @@ -5337,7 +5338,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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', diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.test.ts b/apps/sim/lib/copilot/tools/server/files/create-file.test.ts new file mode 100644 index 00000000000..4c05c422a3a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/create-file.test.ts @@ -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') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index a4f49ef1bf4..73f99fe3484 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -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 @@ -50,7 +62,21 @@ export const createFileServerTool: BaseServerTool ({ + 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) { + 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: '

hi

' }, 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() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index c09a39beb9d..e9a40f6231c 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -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') @@ -218,14 +218,16 @@ export const editContentServerTool: BaseServerTool } -const EXT_TO_MIME: Record = { - '.txt': 'text/plain', - '.md': 'text/markdown', - '.html': 'text/html', - '.json': 'application/json', - '.csv': 'text/csv', - '.pptx': PPTX_MIME, - '.docx': DOCX_MIME, - '.pdf': PDF_MIME, -} - -export function inferContentType(fileName: string, explicitType?: string): string { - if (explicitType) return explicitType - const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() - return EXT_TO_MIME[ext] || 'text/plain' -} - export function validateFlatWorkspaceFileName(fileName: string): string | null { const trimmed = fileName.trim() if (!trimmed) return 'File name cannot be empty' @@ -313,78 +283,6 @@ export const workspaceFileServerTool: BaseServerTool