From a5d654992efb0502b7e8bb5ebea759d5b8f92036 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 11 Sep 2026 12:58:57 -0700 Subject: [PATCH 1/2] fix(file): clear unused content when writing files --- apps/sim/blocks/blocks/file.ts | 14 ++-- .../handlers/generic/file-write.test.ts | 80 +++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 apps/sim/executor/handlers/generic/file-write.test.ts diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index cff055af97e..6b0b83b84e0 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1960,20 +1960,16 @@ export const FileV5Block: BlockConfig = { if (operation === 'file_write') { // Writing stores one file, so the single form. const fileInput = normalizeFileInput(params.writeFileInput, { single: true }) - // The contract counts any defined `content` as "text was provided", and - // an untouched Content box serializes as an empty string — so sending it - // unconditionally would make every file write collide with its own empty - // text box. The selected file is what disambiguates: with one present, - // an empty Content box means "not used" and is dropped, while a - // non-empty one is still forwarded so the contract can report that both - // were filled. With no file, `content` always goes through, which keeps - // writing a deliberately empty text file possible. + /** + * Explicitly clear unused Content because the executor merges these params + * over the original inputs. Preserve empty text when no file is selected. + */ const contentText = typeof params.content === 'string' ? params.content : undefined const omitContent = Boolean(fileInput) && !contentText return { fileName: params.fileName, folderPath: optionalText(params.writeFolderRef), - ...(omitContent ? {} : { content: params.content }), + content: omitContent ? undefined : params.content, ...(fileInput ? { fileInput } : {}), contentType: params.contentType, overwrite: params.overwrite === true || params.overwrite === 'true', diff --git a/apps/sim/executor/handlers/generic/file-write.test.ts b/apps/sim/executor/handlers/generic/file-write.test.ts new file mode 100644 index 00000000000..949dd7c6f42 --- /dev/null +++ b/apps/sim/executor/handlers/generic/file-write.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { createExecutorContext, createSerializedBlock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fileManageWriteBodySchema } from '@/lib/api/contracts/tools/file' +import { FileV5Block } from '@/blocks/blocks/file' +import { getBlock } from '@/blocks/index' +import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' +import { executeTool } from '@/tools' +import { fileWriteTool } from '@/tools/file/write' +import { getTool } from '@/tools/utils' + +vi.mock('@/blocks/index', () => ({ getBlock: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) + +const generatedFile = { + id: 'generated-file', + name: 'report.xlsx', + url: 'https://example.com/report.xlsx', + size: 16978, + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +} + +async function executeWrite(inputs: Record) { + const handler = new GenericBlockHandler() + await handler.execute( + createExecutorContext(), + createSerializedBlock({ type: 'file_v5', tool: 'file_write' }), + { operation: 'file_write', fileName: 'report.xlsx', ...inputs } + ) + const [, params] = vi.mocked(executeTool).mock.calls[0] + return fileManageWriteBodySchema.safeParse(fileWriteTool.operation.input(params)) +} + +describe('File Write executor inputs', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getBlock).mockReturnValue(FileV5Block) + vi.mocked(getTool).mockReturnValue(fileWriteTool) + vi.mocked(executeTool).mockResolvedValue({ success: true, output: {} }) + }) + + it.each([ + ['empty', { content: '' }], + ['null', { content: null }], + ['omitted', {}], + ])('clears %s Content when storing a generated file', async (_label, contentInput) => { + const result = await executeWrite({ ...contentInput, writeFileInput: generatedFile }) + + expect(result.success).toBe(true) + if (!result.success) throw result.error + expect(result.data.content).toBeUndefined() + expect(result.data.fileInput).toEqual(generatedFile) + }) + + it.each(['text', ' '])('rejects file and populated Content %j', async (content) => { + const result = await executeWrite({ content, writeFileInput: generatedFile }) + + expect(result.success).toBe(false) + if (result.success) throw new Error('Expected conflicting inputs to fail validation') + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ['content'], + message: + 'Provide exactly one of content (text to write) or fileInput (an existing file to store).', + }), + ]) + }) + + it.each(['', 'text'])('preserves text-only Content %j', async (content) => { + const result = await executeWrite({ content }) + + expect(result.success).toBe(true) + if (!result.success) throw result.error + expect(result.data.content).toBe(content) + expect(result.data.fileInput).toBeUndefined() + }) +}) From ca6bd9c3ba18cf96aec48e68c68522d7f3dd6dc2 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 11 Sep 2026 13:04:39 -0700 Subject: [PATCH 2/2] fix(file): preserve validation for malformed content --- apps/sim/blocks/blocks/file.ts | 4 ++-- .../executor/handlers/generic/file-write.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 6b0b83b84e0..947d09c1477 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1964,8 +1964,8 @@ export const FileV5Block: BlockConfig = { * Explicitly clear unused Content because the executor merges these params * over the original inputs. Preserve empty text when no file is selected. */ - const contentText = typeof params.content === 'string' ? params.content : undefined - const omitContent = Boolean(fileInput) && !contentText + const omitContent = + Boolean(fileInput) && (params.content == null || params.content === '') return { fileName: params.fileName, folderPath: optionalText(params.writeFolderRef), diff --git a/apps/sim/executor/handlers/generic/file-write.test.ts b/apps/sim/executor/handlers/generic/file-write.test.ts index 949dd7c6f42..27f78599226 100644 --- a/apps/sim/executor/handlers/generic/file-write.test.ts +++ b/apps/sim/executor/handlers/generic/file-write.test.ts @@ -69,6 +69,21 @@ describe('File Write executor inputs', () => { ]) }) + it.each([0, false, { text: 'invalid' }, ['invalid']])( + 'rejects non-string Content %j alongside a file', + async (content) => { + const result = await executeWrite({ content, writeFileInput: generatedFile }) + + expect(result.success).toBe(false) + if (result.success) throw new Error('Expected malformed Content to fail validation') + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['content'], code: 'invalid_type' }), + ]) + ) + } + ) + it.each(['', 'text'])('preserves text-only Content %j', async (content) => { const result = await executeWrite({ content })