Skip to content

Commit f4a86ee

Browse files
authored
fix(file): clear unused content when writing files (#7784)
* fix(file): clear unused content when writing files * fix(file): preserve validation for malformed content
1 parent 281ba8c commit f4a86ee

2 files changed

Lines changed: 102 additions & 11 deletions

File tree

apps/sim/blocks/blocks/file.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,20 +1960,16 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
19601960
if (operation === 'file_write') {
19611961
// Writing stores one file, so the single form.
19621962
const fileInput = normalizeFileInput(params.writeFileInput, { single: true })
1963-
// The contract counts any defined `content` as "text was provided", and
1964-
// an untouched Content box serializes as an empty string — so sending it
1965-
// unconditionally would make every file write collide with its own empty
1966-
// text box. The selected file is what disambiguates: with one present,
1967-
// an empty Content box means "not used" and is dropped, while a
1968-
// non-empty one is still forwarded so the contract can report that both
1969-
// were filled. With no file, `content` always goes through, which keeps
1970-
// writing a deliberately empty text file possible.
1971-
const contentText = typeof params.content === 'string' ? params.content : undefined
1972-
const omitContent = Boolean(fileInput) && !contentText
1963+
/**
1964+
* Explicitly clear unused Content because the executor merges these params
1965+
* over the original inputs. Preserve empty text when no file is selected.
1966+
*/
1967+
const omitContent =
1968+
Boolean(fileInput) && (params.content == null || params.content === '')
19731969
return {
19741970
fileName: params.fileName,
19751971
folderPath: optionalText(params.writeFolderRef),
1976-
...(omitContent ? {} : { content: params.content }),
1972+
content: omitContent ? undefined : params.content,
19771973
...(fileInput ? { fileInput } : {}),
19781974
contentType: params.contentType,
19791975
overwrite: params.overwrite === true || params.overwrite === 'true',
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 { createExecutorContext, createSerializedBlock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { fileManageWriteBodySchema } from '@/lib/api/contracts/tools/file'
7+
import { FileV5Block } from '@/blocks/blocks/file'
8+
import { getBlock } from '@/blocks/index'
9+
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
10+
import { executeTool } from '@/tools'
11+
import { fileWriteTool } from '@/tools/file/write'
12+
import { getTool } from '@/tools/utils'
13+
14+
vi.mock('@/blocks/index', () => ({ getBlock: vi.fn() }))
15+
vi.mock('@/tools', () => ({ executeTool: vi.fn() }))
16+
vi.mock('@/tools/utils', () => ({ getTool: vi.fn() }))
17+
18+
const generatedFile = {
19+
id: 'generated-file',
20+
name: 'report.xlsx',
21+
url: 'https://example.com/report.xlsx',
22+
size: 16978,
23+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
24+
}
25+
26+
async function executeWrite(inputs: Record<string, unknown>) {
27+
const handler = new GenericBlockHandler()
28+
await handler.execute(
29+
createExecutorContext(),
30+
createSerializedBlock({ type: 'file_v5', tool: 'file_write' }),
31+
{ operation: 'file_write', fileName: 'report.xlsx', ...inputs }
32+
)
33+
const [, params] = vi.mocked(executeTool).mock.calls[0]
34+
return fileManageWriteBodySchema.safeParse(fileWriteTool.operation.input(params))
35+
}
36+
37+
describe('File Write executor inputs', () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks()
40+
vi.mocked(getBlock).mockReturnValue(FileV5Block)
41+
vi.mocked(getTool).mockReturnValue(fileWriteTool)
42+
vi.mocked(executeTool).mockResolvedValue({ success: true, output: {} })
43+
})
44+
45+
it.each([
46+
['empty', { content: '' }],
47+
['null', { content: null }],
48+
['omitted', {}],
49+
])('clears %s Content when storing a generated file', async (_label, contentInput) => {
50+
const result = await executeWrite({ ...contentInput, writeFileInput: generatedFile })
51+
52+
expect(result.success).toBe(true)
53+
if (!result.success) throw result.error
54+
expect(result.data.content).toBeUndefined()
55+
expect(result.data.fileInput).toEqual(generatedFile)
56+
})
57+
58+
it.each(['text', ' '])('rejects file and populated Content %j', async (content) => {
59+
const result = await executeWrite({ content, writeFileInput: generatedFile })
60+
61+
expect(result.success).toBe(false)
62+
if (result.success) throw new Error('Expected conflicting inputs to fail validation')
63+
expect(result.error.issues).toEqual([
64+
expect.objectContaining({
65+
path: ['content'],
66+
message:
67+
'Provide exactly one of content (text to write) or fileInput (an existing file to store).',
68+
}),
69+
])
70+
})
71+
72+
it.each([0, false, { text: 'invalid' }, ['invalid']])(
73+
'rejects non-string Content %j alongside a file',
74+
async (content) => {
75+
const result = await executeWrite({ content, writeFileInput: generatedFile })
76+
77+
expect(result.success).toBe(false)
78+
if (result.success) throw new Error('Expected malformed Content to fail validation')
79+
expect(result.error.issues).toEqual(
80+
expect.arrayContaining([
81+
expect.objectContaining({ path: ['content'], code: 'invalid_type' }),
82+
])
83+
)
84+
}
85+
)
86+
87+
it.each(['', 'text'])('preserves text-only Content %j', async (content) => {
88+
const result = await executeWrite({ content })
89+
90+
expect(result.success).toBe(true)
91+
if (!result.success) throw result.error
92+
expect(result.data.content).toBe(content)
93+
expect(result.data.fileInput).toBeUndefined()
94+
})
95+
})

0 commit comments

Comments
 (0)