Skip to content

Commit f03bddc

Browse files
committed
fix(copilot): make stored MIME the source of truth for workspace file writes
1 parent 3096de8 commit f03bddc

7 files changed

Lines changed: 271 additions & 122 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,12 +1305,12 @@ export const CreateFile: ToolCatalogEntry = {
13051305
contentType: {
13061306
type: 'string',
13071307
description:
1308-
'Optional MIME type override. Usually omit and let the system infer from the file extension.',
1308+
'MIME type of the file when using the backward-compatible fileName parameter. Prefer outputs.files[0].mimeType for new calls.',
13091309
},
13101310
fileName: {
13111311
type: 'string',
13121312
description:
1313-
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.',
1313+
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls; when using fileName, contentType is required.',
13141314
},
13151315
outputs: {
13161316
type: 'object',
@@ -1325,7 +1325,8 @@ export const CreateFile: ToolCatalogEntry = {
13251325
properties: {
13261326
mimeType: {
13271327
type: 'string',
1328-
description: 'Optional MIME type override when inference is not enough.',
1328+
description:
1329+
'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.',
13291330
},
13301331
mode: {
13311332
type: 'string',
@@ -1337,7 +1338,7 @@ export const CreateFile: ToolCatalogEntry = {
13371338
description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".',
13381339
},
13391340
},
1340-
required: ['path', 'mode'],
1341+
required: ['path', 'mode', 'mimeType'],
13411342
},
13421343
},
13431344
},
@@ -3793,7 +3794,7 @@ export const OauthRequestAccess: ToolCatalogEntry = {
37933794
providerName: {
37943795
type: 'string',
37953796
description:
3796-
"The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`).",
3797+
"The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.",
37973798
},
37983799
},
37993800
required: ['providerName'],
@@ -5470,7 +5471,7 @@ export const WorkspaceFile: ToolCatalogEntry = {
54705471
contentType: {
54715472
type: 'string',
54725473
description:
5473-
'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
5474+
"Optional MIME type override. Omit to keep the file's existing stored type; pass only to deliberately change it.",
54745475
enum: [
54755476
'text/markdown',
54765477
'text/html',

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,12 +1175,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
11751175
contentType: {
11761176
type: 'string',
11771177
description:
1178-
'Optional MIME type override. Usually omit and let the system infer from the file extension.',
1178+
'MIME type of the file when using the backward-compatible fileName parameter. Prefer outputs.files[0].mimeType for new calls.',
11791179
},
11801180
fileName: {
11811181
type: 'string',
11821182
description:
1183-
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.',
1183+
'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls; when using fileName, contentType is required.',
11841184
},
11851185
outputs: {
11861186
type: 'object',
@@ -1195,7 +1195,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
11951195
properties: {
11961196
mimeType: {
11971197
type: 'string',
1198-
description: 'Optional MIME type override when inference is not enough.',
1198+
description:
1199+
'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.',
11991200
},
12001201
mode: {
12011202
type: 'string',
@@ -1207,7 +1208,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
12071208
description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".',
12081209
},
12091210
},
1210-
required: ['path', 'mode'],
1211+
required: ['path', 'mode', 'mimeType'],
12111212
},
12121213
},
12131214
},
@@ -3662,7 +3663,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
36623663
providerName: {
36633664
type: 'string',
36643665
description:
3665-
"The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`).",
3666+
"The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.",
36663667
},
36673668
},
36683669
required: ['providerName'],
@@ -5337,7 +5338,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
53375338
contentType: {
53385339
type: 'string',
53395340
description:
5340-
'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
5341+
"Optional MIME type override. Omit to keep the file's existing stored type; pass only to deliberately change it.",
53415342
enum: [
53425343
'text/markdown',
53435344
'text/html',
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockEnsureWorkspaceAccess, mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
7+
mockEnsureWorkspaceAccess: vi.fn(),
8+
mockWriteWorkspaceFileByPath: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
12+
ensureWorkspaceAccess: mockEnsureWorkspaceAccess,
13+
}))
14+
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
15+
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
16+
}))
17+
18+
import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file'
19+
20+
const context = { userId: 'user-1', workspaceId: 'ws-1' }
21+
22+
describe('createFileServerTool required MIME', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
mockEnsureWorkspaceAccess.mockResolvedValue({ role: 'admin' })
26+
mockWriteWorkspaceFileByPath.mockResolvedValue({
27+
id: 'file-1',
28+
name: 'notes.md',
29+
vfsPath: 'files/notes.md',
30+
})
31+
})
32+
33+
it('fails without a declared MIME instead of inferring from the extension', async () => {
34+
const result = await createFileServerTool.execute(
35+
{ outputs: { files: [{ path: 'files/notes.md', mode: 'create' }] } } as never,
36+
context
37+
)
38+
39+
expect(result.success).toBe(false)
40+
expect(result.message).toContain('requires an explicit MIME type')
41+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
42+
})
43+
44+
it('rejects a malformed MIME instead of storing it verbatim', async () => {
45+
const result = await createFileServerTool.execute(
46+
{
47+
outputs: { files: [{ path: 'files/notes.md', mode: 'create', mimeType: 'markdown' }] },
48+
} as never,
49+
context
50+
)
51+
52+
expect(result.success).toBe(false)
53+
expect(result.message).toContain('Invalid MIME type "markdown"')
54+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
55+
})
56+
57+
it('normalizes casing and parameters before the MIME becomes the stored type', async () => {
58+
const result = await createFileServerTool.execute(
59+
{
60+
outputs: {
61+
files: [
62+
{ path: 'files/notes.md', mode: 'create', mimeType: 'TEXT/MARKDOWN; charset=UTF-8' },
63+
],
64+
},
65+
} as never,
66+
context
67+
)
68+
69+
expect(result.success).toBe(true)
70+
const args = mockWriteWorkspaceFileByPath.mock.calls[0][0]
71+
expect(args.inferredMimeType).toBe('text/markdown')
72+
expect(args.target.mimeType).toBe('text/markdown')
73+
expect(result.data?.contentType).toBe('text/markdown')
74+
})
75+
76+
it('accepts the legacy fileName + contentType combination', async () => {
77+
const result = await createFileServerTool.execute(
78+
{ fileName: 'notes.md', contentType: 'text/markdown' } as never,
79+
context
80+
)
81+
82+
expect(result.success).toBe(true)
83+
const args = mockWriteWorkspaceFileByPath.mock.calls[0][0]
84+
expect(args.target.path).toBe('files/notes.md')
85+
expect(args.target.mimeType).toBeUndefined()
86+
expect(args.inferredMimeType).toBe('text/markdown')
87+
})
88+
89+
it('still requires a path or fileName', async () => {
90+
const result = await createFileServerTool.execute({} as never, context)
91+
92+
expect(result.success).toBe(false)
93+
expect(result.message).toContain('outputs.files[0].path or fileName')
94+
})
95+
})

apps/sim/lib/copilot/tools/server/files/create-file.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,23 @@ import {
66
type ServerToolContext,
77
} from '@/lib/copilot/tools/server/base-tool'
88
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
9-
import { inferContentType } from './workspace-file'
109

1110
const logger = createLogger('CreateFileServerTool')
1211
const CREATE_FILE_TOOL_ID = 'create_file'
1312

13+
const MIME_SHAPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/
14+
15+
/**
16+
* Normalizes a model-declared MIME before it becomes the stored (load-bearing) file type:
17+
* strips parameters (";charset=..."), trims, lowercases, and rejects anything that is not
18+
* a bare type/subtype token pair. Returns null when the value cannot be a MIME at all, so
19+
* the caller can fail with an instructive message instead of persisting junk verbatim.
20+
*/
21+
function normalizeDeclaredMime(raw: string): string | null {
22+
const bare = raw.split(';')[0].trim().toLowerCase()
23+
return MIME_SHAPE.test(bare) ? bare : null
24+
}
25+
1426
interface CreateFileArgs {
1527
fileName: string
1628
contentType?: string
@@ -50,7 +62,21 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
5062
}
5163
const outputPath =
5264
outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`)
53-
const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType)
65+
const declaredType = outputFile?.mimeType ?? explicitType
66+
if (!declaredType) {
67+
return {
68+
success: false,
69+
message:
70+
'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.',
71+
}
72+
}
73+
const contentType = normalizeDeclaredMime(declaredType)
74+
if (!contentType) {
75+
return {
76+
success: false,
77+
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.`,
78+
}
79+
}
5480
const emptyBuffer = Buffer.from('', 'utf-8')
5581

5682
assertServerToolNotAborted(context)
@@ -61,7 +87,7 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
6187
target: {
6288
path: outputPath,
6389
mode: outputFile?.mode ?? 'create',
64-
mimeType: outputFile?.mimeType,
90+
mimeType: outputFile?.mimeType ? contentType : undefined,
6591
},
6692
buffer: emptyBuffer,
6793
inferredMimeType: contentType,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockConsumeLatestFileIntent, mockUpdateWorkspaceFileContent } = vi.hoisted(() => ({
7+
mockConsumeLatestFileIntent: vi.fn(),
8+
mockUpdateWorkspaceFileContent: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: false }))
12+
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
13+
WorkspaceFile: { id: 'workspace_file' },
14+
}))
15+
vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkspaceAccess: vi.fn() }))
16+
vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: vi.fn() }))
17+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
18+
fetchWorkspaceFileBuffer: vi.fn(),
19+
getWorkspaceFile: vi.fn(),
20+
resolveWorkspaceFileReference: vi.fn(),
21+
updateWorkspaceFileContent: mockUpdateWorkspaceFileContent,
22+
}))
23+
vi.mock('@/lib/workspace-files/orchestration', () => ({
24+
performDeleteWorkspaceFileItems: vi.fn(),
25+
performRenameWorkspaceFile: vi.fn(),
26+
}))
27+
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
28+
compileDoc: vi.fn(),
29+
getE2BDocFormat: vi.fn(async () => null),
30+
DocCompileUserError: class DocCompileUserError extends Error {},
31+
DOCXJS_SOURCE_MIME: 'text/x-docxjs',
32+
PPTXGENJS_SOURCE_MIME: 'text/x-pptxgenjs',
33+
}))
34+
vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
35+
buildEmbeddedImageRefWarning: vi.fn(async () => ''),
36+
}))
37+
vi.mock('@/lib/copilot/tools/server/files/file-intent-store', () => ({
38+
consumeLatestFileIntent: mockConsumeLatestFileIntent,
39+
storeFileIntent: vi.fn(),
40+
}))
41+
42+
import { editContentServerTool } from '@/lib/copilot/tools/server/files/edit-content'
43+
44+
/** Extension-less markdown file — the exact shape of the md->txt reversion regression. */
45+
const markdownRecord = {
46+
id: 'file-1',
47+
workspaceId: 'ws-1',
48+
name: 'new-boi',
49+
key: 'workspace/ws-1/1-abc-new-boi',
50+
path: '/api/files/serve/workspace/ws-1/1-abc-new-boi',
51+
size: 10,
52+
type: 'text/markdown',
53+
uploadedBy: 'user-1',
54+
uploadedAt: new Date('2026-01-01'),
55+
updatedAt: new Date('2026-01-01'),
56+
}
57+
58+
const context = { userId: 'user-1', workspaceId: 'ws-1' }
59+
60+
function intentWith(overrides: Record<string, unknown>) {
61+
return {
62+
operation: 'update',
63+
fileId: markdownRecord.id,
64+
workspaceId: 'ws-1',
65+
userId: 'user-1',
66+
fileRecord: markdownRecord,
67+
createdAt: Date.now(),
68+
...overrides,
69+
}
70+
}
71+
72+
describe('editContentServerTool stored-type preservation', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
mockUpdateWorkspaceFileContent.mockResolvedValue(undefined)
76+
})
77+
78+
it('preserves the stored type when the intent has no contentType (md->txt regression)', async () => {
79+
mockConsumeLatestFileIntent.mockResolvedValue(intentWith({}))
80+
81+
const result = await editContentServerTool.execute({ content: '# hello' }, context)
82+
83+
expect(result.success).toBe(true)
84+
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledTimes(1)
85+
const [, , , , storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
86+
expect(storedMime).toBe('text/markdown')
87+
expect(result.data?.contentType).toBe('text/markdown')
88+
})
89+
90+
it('applies an explicit intent contentType as a deliberate conversion', async () => {
91+
mockConsumeLatestFileIntent.mockResolvedValue(intentWith({ contentType: 'text/html' }))
92+
93+
const result = await editContentServerTool.execute({ content: '<p>hi</p>' }, context)
94+
95+
expect(result.success).toBe(true)
96+
const [, , , , storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
97+
expect(storedMime).toBe('text/html')
98+
})
99+
100+
it('preserves the stored type through a patch write', async () => {
101+
mockConsumeLatestFileIntent.mockResolvedValue(
102+
intentWith({
103+
operation: 'patch',
104+
existingContent: 'Hello World',
105+
edit: { strategy: 'search_replace', search: 'World' },
106+
})
107+
)
108+
109+
const result = await editContentServerTool.execute({ content: 'Sim' }, context)
110+
111+
expect(result.success).toBe(true)
112+
const [, , , buffer, storedMime] = mockUpdateWorkspaceFileContent.mock.calls[0]
113+
expect(buffer.toString('utf-8')).toBe('Hello Sim')
114+
expect(storedMime).toBe('text/markdown')
115+
})
116+
117+
it('fails with guidance when no workspace_file intent exists', async () => {
118+
mockConsumeLatestFileIntent.mockResolvedValue(null)
119+
120+
const result = await editContentServerTool.execute({ content: 'x' }, context)
121+
122+
expect(result.success).toBe(false)
123+
expect(result.message).toContain('workspace_file')
124+
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
125+
})
126+
})

0 commit comments

Comments
 (0)